From fa56b6ecd773061deb4903fa46bbe6dc072e6a3a Mon Sep 17 00:00:00 2001 From: Yumi Izumi Date: Sat, 28 Jun 2025 22:29:10 +0900 Subject: [PATCH] feat: enhance Google OAuth with Gemini CLI integration and Code Assist API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Gemini CLI OAuth credentials format compatibility - Implement Google Cloud Code Assist API integration - Add automatic project discovery functionality - Support both Auth system and Gemini CLI credential storage 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- packages/opencode/src/auth/google.ts | 220 +++++++++++++++++++-- packages/opencode/src/provider/provider.ts | 198 ++++++++++++------- 2 files changed, 325 insertions(+), 93 deletions(-) diff --git a/packages/opencode/src/auth/google.ts b/packages/opencode/src/auth/google.ts index 0908c227..c065d113 100644 --- a/packages/opencode/src/auth/google.ts +++ b/packages/opencode/src/auth/google.ts @@ -2,16 +2,35 @@ import { generatePKCE } from "@openauthjs/openauth/pkce" import { Auth } from "./index" import { createServer } from "http" import { URL } from "url" +import fs from "fs/promises" +import path from "path" +import os from "os" + +interface OAuthCredentials { + access_token: string + refresh_token: string + scope: string + token_type: string + expiry_date: number +} export namespace AuthGoogle { - const CLIENT_ID = process.env["GOOGLE_CLIENT_ID"] || "" - const CLIENT_SECRET = process.env["GOOGLE_CLIENT_SECRET"] || "" + // OAuth configuration from Gemini CLI + const CLIENT_ID = "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com" + const CLIENT_SECRET = "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" const SCOPES = "https://www.googleapis.com/auth/cloud-platform https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile" + const OAUTH_REDIRECT_PORT = 45289 + const OAUTH_CREDS_PATH = path.join(os.homedir(), ".gemini", "oauth_creds.json") + + // Code Assist API configuration + const CODE_ASSIST_ENDPOINT = "https://cloudcode-pa.googleapis.com" + const CODE_ASSIST_API_VERSION = "v1internal" + export async function authorize(): Promise<{ url: string; verifier: string; port: number }> { const pkce = await generatePKCE() - const port = await getAvailablePort() - const redirectUri = `http://localhost:${port}/oauth2callback` + const port = OAUTH_REDIRECT_PORT + const redirectUri = `http://localhost:${port}` const url = new URL("https://accounts.google.com/o/oauth2/v2/auth") url.searchParams.set("client_id", CLIENT_ID) @@ -36,7 +55,7 @@ export namespace AuthGoogle { const server = createServer((req, res) => { const url = new URL(req.url!, `http://localhost:${port}`) - if (url.pathname === "/oauth2callback") { + if (url.pathname === "/") { const code = url.searchParams.get("code") const state = url.searchParams.get("state") @@ -81,7 +100,7 @@ export namespace AuthGoogle { } export async function exchange(code: string, port: number, verifier: string) { - const redirectUri = `http://localhost:${port}/oauth2callback` + const redirectUri = `http://localhost:${port}` const response = await fetch("https://oauth2.googleapis.com/token", { method: "POST", @@ -103,17 +122,44 @@ export namespace AuthGoogle { } const json = await response.json() + + // Save to both Auth system and Gemini CLI format await Auth.set("google", { type: "oauth", refresh: json.refresh_token as string, access: json.access_token as string, expires: Date.now() + json.expires_in * 1000, }) + + // Save in Gemini CLI format + await saveOAuthCredentials({ + access_token: json.access_token as string, + refresh_token: json.refresh_token as string, + scope: SCOPES, + token_type: json.token_type || "Bearer", + expiry_date: Date.now() + json.expires_in * 1000, + }) } export async function access() { - const info = await Auth.get("google") - if (!info || info.type !== "oauth") return + // Try to load from Auth system first + let info = await Auth.get("google") + + // If not found, try to load from Gemini CLI format + if (!info || info.type !== "oauth") { + const geminiCreds = await loadOAuthCredentials() + if (!geminiCreds) return + + // Import Gemini CLI credentials to Auth system + info = { + type: "oauth", + refresh: geminiCreds.refresh_token, + access: geminiCreds.access_token, + expires: geminiCreds.expiry_date, + } + await Auth.set("google", info) + } + if (info.access && info.expires > Date.now()) return info.access const response = await fetch("https://oauth2.googleapis.com/token", { @@ -132,26 +178,164 @@ export namespace AuthGoogle { if (!response.ok) return const json = await response.json() + const newExpiry = Date.now() + json.expires_in * 1000 + + // Update both Auth system and Gemini CLI format await Auth.set("google", { type: "oauth", refresh: info.refresh, // Google doesn't always return a new refresh token access: json.access_token as string, - expires: Date.now() + json.expires_in * 1000, + expires: newExpiry, + }) + + // Update Gemini CLI format + await saveOAuthCredentials({ + access_token: json.access_token as string, + refresh_token: info.refresh, + scope: SCOPES, + token_type: json.token_type || "Bearer", + expiry_date: newExpiry, }) return json.access_token as string } - async function getAvailablePort(): Promise { - return new Promise((resolve) => { - const server = createServer() - server.listen(0, () => { - const address = server.address() - const port = typeof address === 'object' ? address?.port : undefined - server.close() - resolve(port || 3000) - }) + + async function saveOAuthCredentials(credentials: OAuthCredentials): Promise { + const dir = path.dirname(OAUTH_CREDS_PATH) + await fs.mkdir(dir, { recursive: true }) + await fs.writeFile(OAUTH_CREDS_PATH, JSON.stringify(credentials, null, 2)) + } + + async function loadOAuthCredentials(): Promise { + try { + const data = await fs.readFile(OAUTH_CREDS_PATH, "utf8") + return JSON.parse(data) + } catch (err) { + return null + } + } + + /** + * Call a Code Assist API endpoint + */ + export async function callCodeAssistEndpoint(method: string, body: any): Promise { + const accessToken = await access() + if (!accessToken) { + throw new Error("Not authenticated with Google. Please run 'auth login google' first.") + } + + const response = await fetch(`${CODE_ASSIST_ENDPOINT}/${CODE_ASSIST_API_VERSION}:${method}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${accessToken}`, + }, + body: JSON.stringify(body), }) + + if (!response.ok) { + const error = await response.text() + throw new Error(`Code Assist API error: ${response.status} - ${error}`) + } + + return response.json() + } + + /** + * Discover or retrieve the project ID + */ + export async function discoverProjectId(): Promise { + // Check common environment variables for Google Cloud project ID + const envProjectId = process.env["GOOGLE_CLOUD_PROJECT"] || + process.env["GCP_PROJECT"] || + process.env["GCLOUD_PROJECT"] + + // Start with environment project ID if available, otherwise use a placeholder + const initialProjectId = envProjectId || "opencode-oauth-project" + + // Prepare client metadata + const clientMetadata = { + ideType: "IDE_UNSPECIFIED", + platform: "PLATFORM_UNSPECIFIED", + pluginType: "GEMINI", + duetProject: initialProjectId, + } + + try { + // Call loadCodeAssist to discover the actual project ID + const loadRequest = { + cloudaicompanionProject: initialProjectId, + metadata: clientMetadata, + } + + const loadResponse = await callCodeAssistEndpoint("loadCodeAssist", loadRequest) + + // Check if we already have a project ID from the response + if (loadResponse.cloudaicompanionProject) { + return loadResponse.cloudaicompanionProject + } + + // If no existing project, we need to onboard + const defaultTier = loadResponse.allowedTiers?.find((tier: any) => tier.isDefault) + const tierId = defaultTier?.id || "free-tier" + + const onboardRequest = { + tierId: tierId, + cloudaicompanionProject: initialProjectId, + metadata: clientMetadata, + } + + let lroResponse = await callCodeAssistEndpoint("onboardUser", onboardRequest) + + // Poll until operation is complete + while (!lroResponse.done) { + await new Promise((resolve) => setTimeout(resolve, 2000)) + lroResponse = await callCodeAssistEndpoint("onboardUser", onboardRequest) + } + + const discoveredProjectId = lroResponse.response?.cloudaicompanionProject?.id || initialProjectId + return discoveredProjectId + } catch (error: any) { + // Check if this is a permission error + if (error.message?.includes("Permission denied") || error.message?.includes("403")) { + console.error("Permission denied accessing Google Cloud project. This is likely because:") + console.error("1. The project ID doesn't exist or you don't have access to it") + console.error("2. You need to set up a Google Cloud project first") + console.error("\nTo fix this, you can either:") + console.error("- Set the GOOGLE_CLOUD_PROJECT environment variable to your existing project ID") + console.error("- Let the system create a new project by proceeding with onboarding") + + // Try to onboard without a specific project ID + if (!envProjectId) { + try { + // Retry with empty project ID to trigger new project creation + const onboardRequest = { + tierId: "free-tier", + metadata: clientMetadata, + } + + let lroResponse = await callCodeAssistEndpoint("onboardUser", onboardRequest) + + // Poll until operation is complete + while (!lroResponse.done) { + await new Promise((resolve) => setTimeout(resolve, 2000)) + lroResponse = await callCodeAssistEndpoint("onboardUser", onboardRequest) + } + + const discoveredProjectId = lroResponse.response?.cloudaicompanionProject?.id + if (discoveredProjectId) { + return discoveredProjectId + } + } catch (onboardError: any) { + console.error("Onboarding also failed:", onboardError.message) + } + } + } + + console.error("Failed to discover project ID:", error.message) + throw new Error(`Could not discover Google Cloud project ID. ${error.message}`) + } } export class ExchangeFailed extends Error { diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 7e868bd2..1379cd1b 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -121,7 +121,7 @@ export namespace Provider { const access = await AuthGoogle.access() if (!access) return { autoload: false } - // Set cost to 0 for Google account users + // Set cost to 0 for OAuth users (free tier) if (provider && provider.models) { for (const model of Object.values(provider.models)) { model.cost = { @@ -131,92 +131,140 @@ export namespace Provider { } } + // Discover project ID on initialization + let projectId: string | null = null + try { + projectId = await AuthGoogle.discoverProjectId() + } catch (error: any) { + console.error("Failed to discover Google Cloud project ID") + // Don't fail initialization completely - we'll try again on each request + if (error.message?.includes("Permission denied")) { + console.error("You may need to set GOOGLE_CLOUD_PROJECT environment variable") + } + } + return { autoload: true, options: { apiKey: "", baseURL: "https://cloudcode-pa.googleapis.com/v1internal", - async fetch(input: any, init: any) { + async fetch(_input: any, init: any) { const access = await AuthGoogle.access() + if (!access) throw new Error("Not authenticated with Google") - // Transform the request to Code Assist format - let body = init.body - if (body && typeof body === "string") { + // Use discovered project ID or try to discover it + if (!projectId) { try { - const parsed = JSON.parse(body) - if (parsed.messages || parsed.contents) { - // This is a chat completion request, transform it - const transformedBody = { - model: parsed.model || "gemini-2.0-flash", - request: { - contents: - parsed.contents || - parsed.messages?.map((msg: any) => ({ - role: msg.role === "assistant" ? "model" : msg.role, - parts: [ - { text: msg.content || msg.parts?.[0]?.text }, - ], - })), - generationConfig: parsed.generationConfig || {}, - }, - } - body = JSON.stringify(transformedBody) - } - } catch (e) { - // If parsing fails, use original body - } - } - - // Transform URL to use generateContent method - let url = input - if (typeof url === "string" && url.includes("/chat/completions")) { - url = url.replace(/\/chat\/completions.*/, ":generateContent") - } - - const headers = { - ...init.headers, - authorization: `Bearer ${access}`, - "Content-Type": "application/json", - } - delete headers["x-api-key"] - - const response = await fetch(url, { - ...init, - headers, - body, - }) - - // Transform response back to expected format - if (response.ok) { - const data = await response.json() - if (data.candidates) { - // Transform Code Assist response to standard format - const transformed = { - choices: data.candidates.map((candidate: any) => ({ - message: { - role: "assistant", - content: candidate.content?.parts?.[0]?.text || "", - }, - finish_reason: - candidate.finishReason === "STOP" ? "stop" : "length", - })), - usage: data.usageMetadata - ? { - prompt_tokens: data.usageMetadata.promptTokenCount || 0, - completion_tokens: - data.usageMetadata.candidatesTokenCount || 0, - total_tokens: data.usageMetadata.totalTokenCount || 0, - } - : undefined, - } - return new Response(JSON.stringify(transformed), { - status: response.status, - headers: response.headers, + projectId = await AuthGoogle.discoverProjectId() + } catch (error: any) { + console.error("Failed to discover project ID during request:", error.message) + // Return a proper error response instead of continuing with invalid project + return new Response(JSON.stringify({ + error: "Google Cloud project configuration error", + details: "Could not discover or access Google Cloud project. Please set GOOGLE_CLOUD_PROJECT environment variable or ensure you have a valid Google Cloud project.", + message: error.message + }), { + status: 403, + headers: { "Content-Type": "application/json" } }) } } - return response + // Parse the request body + let body = init.body + if (body && typeof body === "string") { + try { + const parsed = JSON.parse(body) + + // Transform to Code Assist format + const transformedBody = { + model: parsed.model || "gemini-2.0-flash", + project: projectId, + request: { + contents: + parsed.contents || + parsed.messages?.map((msg: any) => ({ + role: msg.role === "assistant" ? "model" : msg.role, + parts: [ + { text: msg.content || msg.parts?.[0]?.text }, + ], + })), + generationConfig: { + temperature: parsed.temperature || 0.7, + maxOutputTokens: parsed.max_tokens || 8192, + ...parsed.generationConfig, + }, + }, + } + + // Check if this is a streaming request + const isStreaming = parsed.stream === true + + // Make request to Code Assist API + const endpoint = isStreaming ? ":streamGenerateContent?alt=sse" : ":generateContent" + const response = await fetch( + `https://cloudcode-pa.googleapis.com/v1internal${endpoint}`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${access}`, + }, + body: JSON.stringify(transformedBody), + } + ) + + // For non-streaming responses, transform back to expected format + if (!isStreaming && response.ok) { + const data = await response.json() + if (data.candidates) { + // Transform Code Assist response to standard format + const transformed = { + choices: data.candidates.map((candidate: any) => ({ + message: { + role: "assistant", + content: candidate.content?.parts?.[0]?.text || "", + }, + finish_reason: + candidate.finishReason === "STOP" ? "stop" : "length", + })), + usage: data.usageMetadata + ? { + prompt_tokens: data.usageMetadata.promptTokenCount || 0, + completion_tokens: + data.usageMetadata.candidatesTokenCount || 0, + total_tokens: data.usageMetadata.totalTokenCount || 0, + } + : undefined, + } + return new Response(JSON.stringify(transformed), { + status: response.status, + headers: response.headers, + }) + } + } + + return response + } catch (e) { + console.error("Failed to parse or transform request:", e) + // If we can't parse/transform, return an error response + return new Response(JSON.stringify({ + error: "Failed to process request", + details: e instanceof Error ? e.message : String(e) + }), { + status: 400, + headers: { "Content-Type": "application/json" } + }) + } + } + + // If no body or can't parse, return error + return new Response(JSON.stringify({ + error: "Invalid request body" + }), { + status: 400, + headers: { "Content-Type": "application/json" } + }) }, }, }