From 21b94e48557c3a95d7c0566aa90a45b3e5a2c3a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BD=95=E5=A4=952077?= <274166795@qq.com> Date: Sun, 20 Jul 2025 15:30:09 +0800 Subject: [PATCH] Create gemini-core.js --- gemini-core.js | 332 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 332 insertions(+) create mode 100644 gemini-core.js diff --git a/gemini-core.js b/gemini-core.js new file mode 100644 index 0000000..8087f4e --- /dev/null +++ b/gemini-core.js @@ -0,0 +1,332 @@ +import { OAuth2Client } from 'google-auth-library'; +import * as http from 'http'; +import { promises as fs } from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import * as readline from 'readline'; + +// --- Constants --- +const AUTH_REDIRECT_PORT = 8085; +const CREDENTIALS_DIR = '.gemini'; +const CREDENTIALS_FILE = 'oauth_creds.json'; +const CODE_ASSIST_ENDPOINT = 'https://cloudcode-pa.googleapis.com'; +const CODE_ASSIST_API_VERSION = 'v1internal'; +const OAUTH_CLIENT_ID = '681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com'; +const OAUTH_CLIENT_SECRET = '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'; +export const API_ACTIONS = { + GENERATE_CONTENT: 'generateContent', + STREAM_GENERATE_CONTENT: 'streamGenerateContent', +}; + +// --- Utility Functions --- + +export function ensureRolesInContents(requestBody) { + if (!requestBody || !Array.isArray(requestBody.contents)) { + return requestBody; + } + const newRequestBody = JSON.parse(JSON.stringify(requestBody)); + + // ** FIX: Rename system_instruction to systemInstruction for the internal API ** + // Ensure system_instruction is correctly renamed before further processing + if (newRequestBody.system_instruction) { + newRequestBody.systemInstruction = newRequestBody.system_instruction; + delete newRequestBody.system_instruction; + } + + newRequestBody.contents.forEach((content, index) => { + if (!content.role) { + content.role = (index % 2 === 0) ? 'user' : 'model'; + } + }); + return newRequestBody; +} + +export function formatExpiryTime(expiryTimestamp) { + if (!expiryTimestamp || typeof expiryTimestamp !== 'number') return "No expiry date available"; + const diffMs = expiryTimestamp - Date.now(); + if (diffMs <= 0) return "Token has expired"; + let totalSeconds = Math.floor(diffMs / 1000); + const hours = Math.floor(totalSeconds / 3600); + totalSeconds %= 3600; + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + const pad = (num) => String(num).padStart(2, '0'); + return `${pad(hours)}h ${pad(minutes)}m ${pad(seconds)}s`; +} + +export async function logPrompt(promptText, logMode, logFilename) { + if (logMode === 'none') return; + const logContent = `--- Prompt Log @ ${new Date().toISOString()} ---\n${promptText}\n--------------------------------------\n\n`; + if (logMode === 'console') { + console.log(logContent); + } else if (logMode === 'file') { + try { + await fs.appendFile(logFilename, logContent); + } catch (err) { + console.error(`[Error] Failed to write prompt to ${logFilename}:`, err); + } + } +} + +export function extractPromptText(requestBody) { + if (!requestBody) return "[No request body found]"; + const logParts = []; + + // ** FIX: Check for both snake_case and camelCase for system instruction ** + const systemInstruction = requestBody.system_instruction || requestBody.systemInstruction; + + if (systemInstruction && Array.isArray(systemInstruction.parts)) { + const systemText = systemInstruction.parts.filter(p => p && typeof p.text === 'string').map(p => p.text).join('\n'); + if (systemText) logParts.push(`[SYSTEM INSTRUCTION]\n${systemText}`); + } + + if (Array.isArray(requestBody.contents)) { + const userText = requestBody.contents.flatMap(c => (c && Array.isArray(c.parts) ? c.parts : [])).filter(p => p && typeof p.text === 'string').map(p => p.text).join('\n'); + if (userText) { + if (logParts.length > 0) logParts.push('----------------------'); + logParts.push(`[USER PROMPT]\n${userText}`); + } + } + if (logParts.length === 0) return "[No text prompt or system instruction found]"; + return logParts.join('\n'); +} + +export function extractResponseText(responseObject) { + if (!responseObject || !Array.isArray(responseObject.candidates) || responseObject.candidates.length === 0) return ""; + const firstCandidate = responseObject.candidates[0]; + if (!firstCandidate.content || !Array.isArray(firstCandidate.content.parts)) return ""; + return firstCandidate.content.parts.filter(p => p && typeof p.text === 'string').map(p => p.text).join(""); +} + +function toGeminiApiResponse(codeAssistResponse) { + if (!codeAssistResponse) return null; + const compliantResponse = { candidates: codeAssistResponse.candidates }; + if (codeAssistResponse.promptFeedback) compliantResponse.promptFeedback = codeAssistResponse.promptFeedback; + if (codeAssistResponse.automaticFunctionCallingHistory) compliantResponse.automaticFunctionCallingHistory = codeAssistResponse.automaticFunctionCallingHistory; + return compliantResponse; +} + +export async function getRequestBody(req) { + return new Promise((resolve, reject) => { + let body = ''; + req.on('data', chunk => body += chunk.toString()); + req.on('end', () => { + try { + resolve(body ? JSON.parse(body) : {}); + } catch (e) { + reject(new Error("Invalid JSON in request body.")); + } + }); + req.on('error', err => reject(err)); + }); +} + +// --- Main Service Class --- +export class GeminiApiService { + constructor(host = 'localhost') { + this.authClient = new OAuth2Client(OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET); + this.projectId = null; + this.availableModels = []; + this.isInitialized = false; + this.host = host; + } + + async initialize() { + if (this.isInitialized) return; + console.log('[Service] Initializing Gemini API Service...'); + await this.initializeAuth(); + this.projectId = await this.discoverProjectAndModels(); + this.isInitialized = true; + console.log(`[Service] Initialization complete. Project ID: ${this.projectId}`); + } + + async initializeAuth(forceRefresh = false) { + if (this.authClient.credentials.access_token && !forceRefresh) return; + const credPath = path.join(os.homedir(), CREDENTIALS_DIR, CREDENTIALS_FILE); + try { + const data = await fs.readFile(credPath, "utf8"); + const credentials = JSON.parse(data); + this.authClient.setCredentials(credentials); + console.log('[Auth] Authentication configured successfully from file.'); + if (forceRefresh) { + console.log('[Auth] Forcing token refresh...'); + const { credentials: newCredentials } = await this.authClient.refreshAccessToken(); + this.authClient.setCredentials(newCredentials); + await fs.writeFile(credPath, JSON.stringify(newCredentials, null, 2)); + console.log('[Auth] Refreshed token saved.'); + } + } catch (error) { + if (error.code === 'ENOENT') { + console.log(`[Auth] '${CREDENTIALS_FILE}' not found. Starting new authentication flow...`); + const newTokens = await this.getNewToken(credPath); + this.authClient.setCredentials(newTokens); + console.log('[Auth] New token obtained and loaded into memory.'); + } else { + console.error('[Auth] Failed to initialize authentication from file:', error); + throw new Error(`Failed to load OAuth credentials.`); + } + } + } + + async getNewToken(credPath) { + const redirectUri = `http://${this.host}:${AUTH_REDIRECT_PORT}`; + this.authClient.redirectUri = redirectUri; + return new Promise((resolve, reject) => { + const authUrl = this.authClient.generateAuthUrl({ access_type: 'offline', scope: ['https://www.googleapis.com/auth/cloud-platform'] }); + console.log('\n[Auth] Please open this URL in your browser to authenticate:'); + console.log(authUrl, '\n'); + const server = http.createServer(async (req, res) => { + try { + const url = new URL(req.url, redirectUri); + const code = url.searchParams.get('code'); + const errorParam = url.searchParams.get('error'); + if (code) { + console.log(`[Auth] Received successful callback from Google: ${req.url}`); + res.writeHead(200, { 'Content-Type': 'text/plain' }); + res.end('Authentication successful! You can close this browser tab.'); + server.close(); + const { tokens } = await this.authClient.getToken(code); + await fs.mkdir(path.dirname(credPath), { recursive: true }); + await fs.writeFile(credPath, JSON.stringify(tokens, null, 2)); + console.log('[Auth] New token received and saved to file.'); + resolve(tokens); + } else if (errorParam) { + const errorMessage = `Authentication failed. Google returned an error: ${errorParam}.`; + res.writeHead(400, { 'Content-Type': 'text/plain' }); + res.end(errorMessage); + server.close(); + reject(new Error(errorMessage)); + } else { + console.log(`[Auth] Ignoring irrelevant request: ${req.url}`); + res.writeHead(204); + res.end(); + } + } catch (e) { + if (server.listening) server.close(); + reject(e); + } + }); + server.on('error', (err) => { + if (err.code === 'EADDRINUSE') { + const errorMessage = `[Auth] Port ${AUTH_REDIRECT_PORT} on ${this.host} is already in use.`; + console.error(errorMessage); + reject(new Error(errorMessage)); + } else { + reject(err); + } + }); + server.listen(AUTH_REDIRECT_PORT, this.host); + }); + } + + async discoverProjectAndModels() { + if (this.projectId) return this.projectId; + console.log('[Service] Discovering Project ID...'); + this.availableModels = ['gemini-2.5-pro', 'gemini-2.5-flash']; + console.log(`[Service] Using fixed models: [${this.availableModels.join(', ')}]`); + try { + const loadResponse = await this.callApi('loadCodeAssist', { metadata: { pluginType: 'GEMINI' } }); + if (loadResponse.cloudaicompanionProject) { + return loadResponse.cloudaicompanionProject; + } + const defaultTier = loadResponse.allowedTiers?.find(tier => tier.isDefault); + const onboardRequest = { tierId: defaultTier?.id || 'free-tier', metadata: { pluginType: 'GEMINI' } }; + let lro = await this.callApi('onboardUser', onboardRequest); + while (!lro.done) { + await new Promise(resolve => setTimeout(resolve, 2000)); + lro = await this.callApi('onboardUser', onboardRequest); + } + return lro.response?.cloudaicompanionProject?.id; + } catch (error) { + console.error('[Service] Failed to discover Project ID:', error.response?.data || error.message); + throw new Error('Could not discover a valid Google Cloud Project ID.'); + } + } + + async listModels() { + if (!this.isInitialized) await this.initialize(); + const formattedModels = this.availableModels.map(modelId => { + const displayName = modelId.split('-').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' '); + return { + name: `models/${modelId}`, version: "1.0.0", displayName: displayName, + description: `A generative model for text and chat generation. ID: ${modelId}`, + inputTokenLimit: 32768, outputTokenLimit: 8192, + supportedGenerationMethods: ["generateContent", "streamGenerateContent"], + }; + }); + return { models: formattedModels }; + } + + async callApi(method, body, isRetry = false) { + try { + const res = await this.authClient.request({ + url: `${CODE_ASSIST_ENDPOINT}/${CODE_ASSIST_API_VERSION}:${method}`, + method: "POST", headers: { "Content-Type": "application/json" }, + responseType: "json", body: JSON.stringify(body), + }); + return res.data; + } catch (error) { + if (error.response?.status === 401 && !isRetry) { + console.log('[API] Received 401. Refreshing auth and retrying...'); + await this.initializeAuth(true); + return this.callApi(method, body, true); + } + throw error; + } + } + + async * streamApi(method, body, isRetry = false) { + try { + const res = await this.authClient.request({ + url: `${CODE_ASSIST_ENDPOINT}/${CODE_ASSIST_API_VERSION}:${method}`, + method: "POST", params: { alt: "sse" }, headers: { "Content-Type": "application/json" }, + responseType: "stream", body: JSON.stringify(body), + }); + if (res.status !== 200) { + let errorBody = ''; + for await (const chunk of res.data) errorBody += chunk.toString(); + throw new Error(`Upstream API Error (Status ${res.status}): ${errorBody}`); + } + yield* this.parseSSEStream(res.data); + } catch (error) { + if (error.response?.status === 401 && !isRetry) { + console.log('[API] Received 401 during stream. Refreshing auth and retrying...'); + await this.initializeAuth(true); + yield* this.streamApi(method, body, true); + return; + } + throw error; + } + } + + async * parseSSEStream(stream) { + const rl = readline.createInterface({ input: stream, crlfDelay: Infinity }); + let buffer = []; + for await (const line of rl) { + if (line.startsWith("data: ")) buffer.push(line.slice(6)); + else if (line === "" && buffer.length > 0) { + try { yield JSON.parse(buffer.join('\n')); } catch (e) { console.error("[Stream] Failed to parse JSON chunk:", buffer.join('\n')); } + buffer = []; + } + } + if (buffer.length > 0) { + try { yield JSON.parse(buffer.join('\n')); } catch (e) { console.error("[Stream] Failed to parse final JSON chunk:", buffer.join('\n')); } + } + } + + async generateContent(model, requestBody) { + const compliantRequestBody = ensureRolesInContents(requestBody); + const apiRequest = { model, project: this.projectId, request: compliantRequestBody }; + const response = await this.callApi(API_ACTIONS.GENERATE_CONTENT, apiRequest); + return toGeminiApiResponse(response.response); + } + + async * generateContentStream(model, requestBody) { + const compliantRequestBody = ensureRolesInContents(requestBody); + const apiRequest = { model, project: this.projectId, request: compliantRequestBody }; + const stream = this.streamApi(API_ACTIONS.STREAM_GENERATE_CONTENT, apiRequest); + for await (const chunk of stream) { + yield toGeminiApiResponse(chunk.response); + } + } +}