AIClient-2-API/src/gemini/gemini-core.js
hex2077 b9d77090c9 feat(gemini-core): 添加客户端元数据并支持默认项目ID
在调用API时添加完整的客户端元数据信息,包括IDE类型、平台和插件类型等,并使用默认项目ID进行初始化。这有助于服务端更好地识别客户端环境并提供更准确的响应。
2025-10-05 14:44:27 +08:00

357 lines
18 KiB
JavaScript

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';
import { API_ACTIONS, ensureRolesInContents, formatExpiryTime } from '../common.js';
// --- 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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';
const GEMINI_MODELS = ['gemini-2.5-flash', 'gemini-2.5-flash-lite', 'gemini-2.5-pro'];
function toGeminiApiResponse(codeAssistResponse) {
if (!codeAssistResponse) return null;
const compliantResponse = { candidates: codeAssistResponse.candidates };
if (codeAssistResponse.usageMetadata) compliantResponse.usageMetadata = codeAssistResponse.usageMetadata;
if (codeAssistResponse.promptFeedback) compliantResponse.promptFeedback = codeAssistResponse.promptFeedback;
if (codeAssistResponse.automaticFunctionCallingHistory) compliantResponse.automaticFunctionCallingHistory = codeAssistResponse.automaticFunctionCallingHistory;
return compliantResponse;
}
export class GeminiApiService {
constructor(config) {
this.authClient = new OAuth2Client(OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET);
this.availableModels = [];
this.isInitialized = false;
this.config = config;
this.host = config.HOST;
this.oauthCredsBase64 = config.GEMINI_OAUTH_CREDS_BASE64;
this.oauthCredsFilePath = config.GEMINI_OAUTH_CREDS_FILE_PATH;
this.projectId = config.PROJECT_ID;
}
async initialize() {
if (this.isInitialized) return;
console.log('[Gemini] Initializing Gemini API Service...');
await this.initializeAuth();
if (!this.projectId) {
this.projectId = await this.discoverProjectAndModels();
} else {
console.log(`[Gemini] Using provided Project ID: ${this.projectId}`);
this.availableModels = GEMINI_MODELS;
console.log(`[Gemini] Using fixed models: [${this.availableModels.join(', ')}]`);
}
if (this.projectId === 'default') {
throw new Error("Error: 'default' is not a valid project ID. Please provide a valid Google Cloud Project ID using the --project-id argument.");
}
this.isInitialized = true;
console.log(`[Gemini] Initialization complete. Project ID: ${this.projectId}`);
}
async initializeAuth(forceRefresh = false) {
if (this.authClient.credentials.access_token && !forceRefresh) return;
if (this.oauthCredsBase64) {
try {
const decoded = Buffer.from(this.oauthCredsBase64, 'base64').toString('utf8');
const credentials = JSON.parse(decoded);
this.authClient.setCredentials(credentials);
console.log('[Gemini Auth] Authentication configured successfully from base64 string.');
return;
} catch (error) {
console.error('[Gemini Auth] Failed to parse base64 OAuth credentials:', error);
throw new Error(`Failed to load OAuth credentials from base64 string.`);
}
}
const credPath = this.oauthCredsFilePath || 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('[Gemini Auth] Authentication configured successfully from file.');
if (forceRefresh) {
console.log('[Gemini 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('[Gemini Auth] Token refresh response: ok');
}
} catch (error) {
console.error('[Gemini Auth] Error initializing authentication:', error.code);
if (error.code === 'ENOENT' || error.code === 400) {
console.log(`[Gemini Auth] Credentials file '${credPath}' not found. Starting new authentication flow...`);
const newTokens = await this.getNewToken(credPath);
this.authClient.setCredentials(newTokens);
console.log('[Gemini Auth] New token obtained and loaded into memory.');
} else {
console.error('[Gemini Auth] Failed to initialize authentication from file:', error);
throw new Error(`Failed to load OAuth credentials.`);
}
}
}
async getNewToken(credPath) {
let host = this.host;
if (!host || host === 'undefined') {
host = '127.0.0.1';
}
const redirectUri = `http://${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[Gemini 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(`[Gemini 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('[Gemini 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(`[Gemini 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 = `[Gemini 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) {
console.log(`[Gemini] Using pre-configured Project ID: ${this.projectId}`);
return this.projectId;
}
console.log('[Gemini] Discovering Project ID...');
this.availableModels = GEMINI_MODELS;
console.log(`[Gemini] Using fixed models: [${this.availableModels.join(', ')}]`);
try {
const initialProjectId = "default"
// Prepare client metadata
const clientMetadata = {
ideType: "IDE_UNSPECIFIED",
platform: "PLATFORM_UNSPECIFIED",
pluginType: "GEMINI",
duetProject: initialProjectId,
}
const loadResponse = await this.callApi('loadCodeAssist', { metadata: clientMetadata });
if (loadResponse.cloudaicompanionProject) {
return loadResponse.cloudaicompanionProject;
}
const defaultTier = loadResponse.allowedTiers?.find(tier => tier.isDefault);
const onboardRequest = { tierId: defaultTier?.id || 'free-tier', metadata: clientMetadata , cloudaicompanionProject: initialProjectId,};
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('[Gemini] 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, retryCount = 0) {
const maxRetries = this.config.REQUEST_MAX_RETRIES;
const baseDelay = this.config.REQUEST_BASE_DELAY; // 1 second base delay
try {
const requestOptions = {
url: `${CODE_ASSIST_ENDPOINT}/${CODE_ASSIST_API_VERSION}:${method}`,
method: "POST",
headers: { "Content-Type": "application/json" },
responseType: "json",
body: JSON.stringify(body),
};
const res = await this.authClient.request(requestOptions);
return res.data;
} catch (error) {
if ((error.response?.status === 400 || error.response?.status === 401) && !isRetry) {
console.log('[API] Received 401. Refreshing auth and retrying...');
await this.initializeAuth(true);
return this.callApi(method, body, true, retryCount);
}
// Handle 429 (Too Many Requests) with exponential backoff
if (error.response?.status === 429 && retryCount < maxRetries) {
const delay = baseDelay * Math.pow(2, retryCount);
console.log(`[API] Received 429 (Too Many Requests). Retrying in ${delay}ms... (attempt ${retryCount + 1}/${maxRetries})`);
await new Promise(resolve => setTimeout(resolve, delay));
return this.callApi(method, body, isRetry, retryCount + 1);
}
// Handle other retryable errors (5xx server errors)
if (error.response?.status >= 500 && error.response?.status < 600 && retryCount < maxRetries) {
const delay = baseDelay * Math.pow(2, retryCount);
console.log(`[API] Received ${error.response.status} server error. Retrying in ${delay}ms... (attempt ${retryCount + 1}/${maxRetries})`);
await new Promise(resolve => setTimeout(resolve, delay));
return this.callApi(method, body, isRetry, retryCount + 1);
}
throw error;
}
}
async * streamApi(method, body, isRetry = false, retryCount = 0) {
const maxRetries = this.config.REQUEST_MAX_RETRIES;
const baseDelay = this.config.REQUEST_BASE_DELAY; // 1 second base delay
try {
const requestOptions = {
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),
};
const res = await this.authClient.request(requestOptions);
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 === 400 || 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, retryCount);
return;
}
// Handle 429 (Too Many Requests) with exponential backoff
if (error.response?.status === 429 && retryCount < maxRetries) {
const delay = baseDelay * Math.pow(2, retryCount);
console.log(`[API] Received 429 (Too Many Requests) during stream. Retrying in ${delay}ms... (attempt ${retryCount + 1}/${maxRetries})`);
await new Promise(resolve => setTimeout(resolve, delay));
yield* this.streamApi(method, body, isRetry, retryCount + 1);
return;
}
// Handle other retryable errors (5xx server errors)
if (error.response?.status >= 500 && error.response?.status < 600 && retryCount < maxRetries) {
const delay = baseDelay * Math.pow(2, retryCount);
console.log(`[API] Received ${error.response.status} server error during stream. Retrying in ${delay}ms... (attempt ${retryCount + 1}/${maxRetries})`);
await new Promise(resolve => setTimeout(resolve, delay));
yield* this.streamApi(method, body, isRetry, retryCount + 1);
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) {
console.log(`[Auth Token] Time until expiry: ${formatExpiryTime(this.authClient.credentials.expiry_date)}`);
let selectedModel = model;
if (!GEMINI_MODELS.includes(model)) {
console.warn(`[Gemini] Model '${model}' not found. Using default model: '${GEMINI_MODELS[0]}'`);
selectedModel = GEMINI_MODELS[0];
}
const processedRequestBody = ensureRolesInContents(requestBody);
const apiRequest = { model: selectedModel, project: this.projectId, request: processedRequestBody };
const response = await this.callApi(API_ACTIONS.GENERATE_CONTENT, apiRequest);
return toGeminiApiResponse(response.response);
}
async * generateContentStream(model, requestBody) {
console.log(`[Auth Token] Time until expiry: ${formatExpiryTime(this.authClient.credentials.expiry_date)}`);
let selectedModel = model;
if (!GEMINI_MODELS.includes(model)) {
console.warn(`[Gemini] Model '${model}' not found. Using default model: '${GEMINI_MODELS[0]}'`);
selectedModel = GEMINI_MODELS[0];
}
const processedRequestBody = ensureRolesInContents(requestBody);
const apiRequest = { model: selectedModel, project: this.projectId, request: processedRequestBody };
const stream = this.streamApi(API_ACTIONS.STREAM_GENERATE_CONTENT, apiRequest);
for await (const chunk of stream) {
yield toGeminiApiResponse(chunk.response);
}
}
/**
* Checks if the given expiry date is within the next 10 minutes from now.
* @returns {boolean} True if the expiry date is within the next 10 minutes, false otherwise.
*/
isExpiryDateNear() {
try {
const currentTime = Date.now();
const cronNearMinutesInMillis = (this.config.CRON_NEAR_MINUTES || 10) * 60 * 1000;
console.log(`[Gemini] Expiry date: ${this.authClient.credentials.expiry_date}, Current time: ${currentTime}, ${this.config.CRON_NEAR_MINUTES || 10} minutes from now: ${currentTime + cronNearMinutesInMillis}`);
return this.authClient.credentials.expiry_date <= (currentTime + cronNearMinutesInMillis);
} catch (error) {
console.error(`[Gemini] Error checking expiry date: ${error.message}`);
return false;
}
}
}