This commit is contained in:
Yumi Izumi 2025-07-07 02:48:03 -03:00 committed by GitHub
commit 762269193a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 555 additions and 0 deletions

View file

@ -0,0 +1,346 @@
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 {
// 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 = 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)
url.searchParams.set("redirect_uri", redirectUri)
url.searchParams.set("response_type", "code")
url.searchParams.set("scope", SCOPES)
url.searchParams.set("code_challenge", pkce.challenge)
url.searchParams.set("code_challenge_method", "S256")
url.searchParams.set("access_type", "offline")
url.searchParams.set("prompt", "consent")
url.searchParams.set("state", pkce.verifier)
return {
url: url.toString(),
verifier: pkce.verifier,
port
}
}
export async function startCallbackServer(port: number, verifier: string): Promise<string> {
return new Promise((resolve, reject) => {
const server = createServer((req, res) => {
const url = new URL(req.url!, `http://localhost:${port}`)
if (url.pathname === "/") {
const code = url.searchParams.get("code")
const state = url.searchParams.get("state")
if (code && state === verifier) {
res.writeHead(200, { "Content-Type": "text/html" })
res.end(`
<html>
<body>
<h1>Authentication successful!</h1>
<p>You can close this window and return to the terminal.</p>
</body>
</html>
`)
server.close()
resolve(code)
} else {
res.writeHead(400, { "Content-Type": "text/html" })
res.end(`
<html>
<body>
<h1>Authentication failed!</h1>
<p>Invalid or missing authorization code.</p>
</body>
</html>
`)
server.close()
reject(new Error("Invalid or missing authorization code"))
}
} else {
res.writeHead(404)
res.end()
}
})
server.listen(port)
setTimeout(() => {
server.close()
reject(new Error("OAuth callback timeout"))
}, 300000) // 5 minute timeout
})
}
export async function exchange(code: string, port: number, verifier: string) {
const redirectUri = `http://localhost:${port}`
const response = await fetch("https://oauth2.googleapis.com/token", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
code,
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
redirect_uri: redirectUri,
grant_type: "authorization_code",
code_verifier: verifier,
}).toString(),
})
if (!response.ok) {
throw new ExchangeFailed()
}
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() {
// 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", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: info.refresh,
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
}).toString(),
})
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: 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 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 {
constructor() {
super("OAuth token exchange failed")
}
}
}

View file

@ -1,5 +1,6 @@
import { AuthAnthropic } from "../../auth/anthropic"
import { AuthCopilot } from "../../auth/copilot"
import { AuthGoogle } from "../../auth/google"
import { Auth } from "../../auth"
import { cmd } from "./cmd"
import * as prompts from "@clack/prompts"
@ -181,6 +182,61 @@ export const AuthLoginCommand = cmd({
}
}
if (provider === "google") {
const method = await prompts.select({
message: "Login method",
options: [
{
label: "Login with Google",
value: "oauth",
},
{
label: "API Key",
value: "api",
},
],
})
if (prompts.isCancel(method)) throw new UI.CancelledError()
if (method === "oauth") {
// some weird bug where program exits without this
await new Promise((resolve) => setTimeout(resolve, 10))
const { url, verifier, port } = await AuthGoogle.authorize()
prompts.note("Starting local server for OAuth callback...")
// Start the callback server before opening the browser
const codePromise = AuthGoogle.startCallbackServer(port, verifier)
prompts.note("Opening browser for authentication...")
try {
await open(url)
} catch (e) {
prompts.log.error(
"Failed to open browser. Please open the following URL manually:",
)
}
prompts.log.info(url)
const spinner = prompts.spinner()
spinner.start("Waiting for Google authentication...")
try {
const code = await codePromise
spinner.stop("Authentication received")
await AuthGoogle.exchange(code, port, verifier)
prompts.log.success("Login successful")
} catch (error) {
spinner.stop("Authentication failed", 1)
prompts.log.error(error instanceof Error ? error.message : String(error))
}
prompts.outro("Done")
return
}
}
const copilot = await AuthCopilot()
if (provider === "github-copilot" && copilot) {
await new Promise((resolve) => setTimeout(resolve, 10))

View file

@ -18,6 +18,7 @@ import { WriteTool } from "../tool/write"
import { TodoReadTool, TodoWriteTool } from "../tool/todo"
import { AuthAnthropic } from "../auth/anthropic"
import { AuthCopilot } from "../auth/copilot"
import { AuthGoogle } from "../auth/google"
import { ModelsDev } from "./models"
import { NamedError } from "../util/error"
import { Auth } from "../auth"
@ -128,6 +129,158 @@ export namespace Provider {
},
}
},
google: async (provider) => {
const access = await AuthGoogle.access()
if (!access) return { autoload: false }
// Set cost to 0 for OAuth users (free tier)
if (provider && provider.models) {
for (const model of Object.values(provider.models)) {
model.cost = {
input: 0,
output: 0,
}
}
}
// 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) {
const access = await AuthGoogle.access()
if (!access) throw new Error("Not authenticated with Google")
// Use discovered project ID or try to discover it
if (!projectId) {
try {
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" }
})
}
}
// 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" }
})
},
},
}
},
openai: async () => {
return {
autoload: false,