mirror of
https://github.com/sst/opencode.git
synced 2025-07-07 16:14:59 +00:00
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:
parent
d2864716ec
commit
fa56b6ecd7
2 changed files with 325 additions and 93 deletions
|
@ -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 {
|
||||
|
|
|
@ -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" }
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue