mirror of
https://github.com/sst/opencode.git
synced 2025-07-07 16:14:59 +00:00
Merge fa56b6ecd7
into d87922c0eb
This commit is contained in:
commit
762269193a
3 changed files with 555 additions and 0 deletions
346
packages/opencode/src/auth/google.ts
Normal file
346
packages/opencode/src/auth/google.ts
Normal 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")
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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))
|
||||
|
|
|
@ -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,
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue