feat: enhance Google OAuth with Gemini CLI integration and Code Assist API

- 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 <noreply@anthropic.com>
This commit is contained in:
Yumi Izumi 2025-06-28 22:29:10 +09:00
parent d2864716ec
commit fa56b6ecd7
No known key found for this signature in database
GPG key ID: 69B062708BAB05A2
2 changed files with 325 additions and 93 deletions

View file

@ -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<number> {
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<void> {
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<OAuthCredentials | null> {
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<any> {
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<string> {
// 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 {

View file

@ -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" }
})
},
},
}