AIClient-2-API/gemini-api-server.js
hex2077 ea0e49f568 feat(日志): 重构日志系统并添加系统提示管理功能
- 将logPrompt重命名为logConversation以支持输入输出日志
- 添加manageSystemPrompt函数来管理系统提示文本文件
- 在流式和非流式请求中记录完整响应文本
- 改进提示文本提取逻辑以获取最新用户输入
2025-07-21 14:28:53 +08:00

321 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*
* 描述:
* (最终生产可用版)
* 该脚本创建了一个独立的 Node.js HTTP 服务器,作为 Google Cloud Code Assist API 的本地代理。
* 此版本包含了所有功能和错误修复,设计稳健、灵活,并通过全面且可控的日志系统使其易于监控。
*
* 主要功能:
* - 灵活的 API 密钥校验: 只要在 URL 查询参数 (`?key=...`) 或 `x-goog-api-key` 请求头中提供了正确的密钥,请求即可通过授权。密钥可通过 `--api-key` 启动参数设置。
* - 角色规范化修复: 自动为请求体添加必需的 'user'/'model' 角色,并正确保留 `systemInstruction` (或 `system_instruction`)。
* - 固定的模型列表: 服务器现在专门提供并使用 `gemini-2.5-pro` 和 `gemini-2.5-flash` 模型。
* - 完整的 Gemini API 端点支持: 实现了 `listModels`, `generateContent`, `streamGenerateContent`。
* - 全面且可控的日志系统: 包括令牌剩余有效期、可输出到控制台或文件的带时间戳的提示词日志等。
*
* -----------------------------------------------------------------------------
* 使用说明 & 命令行示例
* -----------------------------------------------------------------------------
*
* 1. 环境设置:
* // 在脚本所在目录创建一个 `package.json` 文件,内容为: {"type": "module"}
* // 以避免模块类型警告。
*
* // 安装依赖:
* npm install google-auth-library
*
* 2. 启动服务 (根据需要组合使用以下参数):
*
* // 默认启动: 监听 localhost不打印提示词
* node gemini-api-server-final.js
*
* // 指定监听IP: 监听所有网络接口 (例如,用于 Docker 或局域网访问)
* node gemini-api-server-final.js 0.0.0.0
*
* // 打印提示词到控制台: 监听 localhost并在控制台输出提示词详情
* node gemini-api-server-final.js --log-prompts console
*
* // 打印提示词到文件: 监听 localhost并将提示词详情保存到一个带启动时间戳的新文件中
* // (例如: prompts-20231027-153055.log)
* node gemini-api-server-final.js --log-prompts file
*
* // 组合使用参数 (参数顺序无关):
* // 在指定 IP 上运行,并打印提示词到控制台
* node gemini-api-server-final.js 192.168.1.100 --log-prompts console
*
* // 在所有网络接口上运行,并打印提示词到文件
* node gemini-api-server-final.js --log-prompts file 0.0.0.0
*
* // 指定 API Key 和端口 (参数顺序无关)
* node gemini-api-server-final.js --api-key your_secret_key --port 3001
*
* // 通过 base64 编码的凭证启动 (例如,用于 Docker 或 CI/CD 环境)
* node gemini-api-server.js --oauth-creds-base64 "YOUR_BASE64_ENCODED_OAUTH_CREDS_JSON"
*
* // 通过指定凭证文件路径启动 (例如,用于自定义凭证位置)
* node gemini-api-server.js --oauth-creds-file "/path/to/your/oauth_creds.json"
*
* // 通过指定项目ID启动 (例如,用于多项目环境)
* node gemini-api-server.js --project-id your-gcp-project-id
*
* 3. 调用 API 接口 (默认 API Key: 123456):
*
* // a) 列出可用模型 (GET 请求,密钥在 URL 参数中)
* curl "http://localhost:3000/v1beta/models?key=123456"
*
* // b) 生成内容 - 单轮对话 (POST 请求,密钥在请求头中)
* curl "http://localhost:3000/v1beta/models/gemini-2.5-pro:generateContent" \
* -H "Content-Type: application/json" \
* -H "x-goog-api-key: 123456" \
* -d '{"contents":[{"parts":[{"text":"用一句话解释什么是代理服务器"}]}]}'
*
* // c) 生成内容 - 带系统提示词 (POST 请求,密钥在请求头中,注意 system_instruction)
* curl "http://localhost:3000/v1beta/models/gemini-2.5-pro:generateContent" \
* -H "Content-Type: application/json" \
* -H "x-goog-api-key: 123456" \
* -d '{
* "system_instruction": { "parts": [{ "text": "你是一只名叫 Neko 的猫。" }] },
* "contents": [{ "parts": [{ "text": "你好,你叫什么名字?" }] }]
* }'
*
* // d) 流式生成内容 (POST 请求,密钥在 URL 参数中)
* curl "http://localhost:3000/v1beta/models/gemini-2.5-flash:streamGenerateContent?key=123456" \
* -H "Content-Type: application/json" \
* -d '{"contents":[{"parts":[{"text":"写一首关于宇宙的五行短诗"}]}]}'
*
*/
import * as http from 'http';
import {
GeminiApiService,
API_ACTIONS,
formatExpiryTime,
logConversation, // Changed from logPrompt
extractPromptText,
extractResponseText,
getRequestBody,
manageSystemPrompt,
} from './gemini-core.js';
// --- Configuration Parsing ---
let HOST = 'localhost';
let PROMPT_LOG_MODE = 'none'; // 'none', 'console', 'file'
const PROMPT_LOG_BASE_NAME = 'prompts';
let PROMPT_LOG_FILENAME = '';
let REQUIRED_API_KEY = '123456'; // Default API Key
let SERVER_PORT = 3000; // Default Port
let OAUTH_CREDS_BASE64 = null; // New variable for base64 encoded OAuth credentials
let OAUTH_CREDS_FILE_PATH = null; // New variable for OAuth credentials file path
let PROJECT_ID = null; // New variable for project ID
const args = process.argv.slice(2);
const remainingArgs = [];
for (let i = 0; i < args.length; i++) {
if (args[i] === '--api-key') {
if (i + 1 < args.length) {
REQUIRED_API_KEY = args[i + 1];
i++; // Skip the value
} else {
console.warn(`[Config Warning] --api-key flag requires a value.`);
}
} else if (args[i] === '--log-prompts') {
if (i + 1 < args.length) {
const mode = args[i + 1];
if (mode === 'console' || mode === 'file') {
PROMPT_LOG_MODE = mode;
} else {
console.warn(`[Config Warning] Invalid mode for --log-prompts. Expected 'console' or 'file'. Prompt logging is disabled.`);
}
i++; // Skip the value
} else {
console.warn(`[Config Warning] --log-prompts flag requires a value.`);
}
} else if (args[i] === '--port') {
if (i + 1 < args.length) {
SERVER_PORT = parseInt(args[i + 1], 10);
i++; // Skip the value
} else {
console.warn(`[Config Warning] --port flag requires a value.`);
}
} else if (args[i] === '--oauth-creds-base64') {
if (i + 1 < args.length) {
OAUTH_CREDS_BASE64 = args[i + 1];
i++; // Skip the value
} else {
console.warn(`[Config Warning] --oauth-creds-base64 flag requires a value.`);
}
} else if (args[i] === '--oauth-creds-file') {
if (i + 1 < args.length) {
OAUTH_CREDS_FILE_PATH = args[i + 1];
i++; // Skip the value
} else {
console.warn(`[Config Warning] --oauth-creds-file flag requires a value.`);
}
} else if (args[i] === '--project-id') { // New argument for project ID
if (i + 1 < args.length) {
PROJECT_ID = args[i + 1];
i++; // Skip the value
} else {
console.warn(`[Config Warning] --project-id flag requires a value.`);
}
} else {
remainingArgs.push(args[i]);
}
}
if (remainingArgs.length > 0) {
HOST = remainingArgs[0];
}
if (PROMPT_LOG_MODE === 'file') {
const now = new Date();
const pad = (num) => num.toString().padStart(2, '0');
const timestamp = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
PROMPT_LOG_FILENAME = `${PROMPT_LOG_BASE_NAME}-${timestamp}.log`;
}
// --- Constants ---
// SERVER_PORT is now a configurable variable
function isAuthorized(req, requestUrl) {
const queryKey = requestUrl.searchParams.get('key');
const headerKey = req.headers['x-goog-api-key'];
if (queryKey === REQUIRED_API_KEY || headerKey === REQUIRED_API_KEY) {
return true;
}
console.log(`[Auth] Unauthorized request denied. Query key: "${queryKey}", Header key: "${headerKey}"`);
return false;
}
// --- Singleton Instance & HTTP Server Handlers ---
let apiServiceInstance = null;
async function getApiService() {
if (!apiServiceInstance) {
apiServiceInstance = new GeminiApiService(HOST, OAUTH_CREDS_BASE64, OAUTH_CREDS_FILE_PATH, PROJECT_ID);
await apiServiceInstance.initialize();
} else if (!apiServiceInstance.isInitialized) {
await apiServiceInstance.initialize();
}
return apiServiceInstance;
}
async function handleStreamRequest(res, service, model, requestBody) {
res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive", "Transfer-Encoding": "chunked" });
const stream = service.generateContentStream(model, requestBody);
console.log('[Server Response Stream]');
process.stdout.write('> ');
let fullResponseText = '';
for await (const chunk of stream) {
const chunkText = extractResponseText(chunk);
if (chunkText) {
process.stdout.write(chunkText);
fullResponseText += chunkText;
}
const chunkString = JSON.stringify(chunk);
res.write(`data: ${chunkString}\n\n`);
}
process.stdout.write('\n');
res.end();
const expiryDate = service.authClient.credentials.expiry_date;
console.log(`[Auth Token] Time until expiry: ${formatExpiryTime(expiryDate)}`);
await logConversation('output', fullResponseText, PROMPT_LOG_MODE, PROMPT_LOG_FILENAME);
}
async function handleUnaryRequest(res, service, model, requestBody) {
const response = await service.generateContent(model, requestBody);
console.log('[Server Response Unary]');
process.stdout.write('> ');
const responseText = extractResponseText(response);
process.stdout.write(responseText);
process.stdout.write('\n');
const responseString = JSON.stringify(response);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(responseString);
const expiryDate = service.authClient.credentials.expiry_date;
console.log(`[Auth Token] Time until expiry: ${formatExpiryTime(expiryDate)}`);
await logConversation('output', responseText, PROMPT_LOG_MODE, PROMPT_LOG_FILENAME);
}
function handleError(res, error) {
console.error('\n[Server] Request failed:', error.stack);
if (!res.headersSent) {
const statusCode = error.response?.status || 500;
res.writeHead(statusCode, { 'Content-Type': 'application/json' });
}
const errorPayload = { error: { message: error.message, details: error.response?.data } };
res.end(JSON.stringify(errorPayload));
}
async function requestHandler(req, res) {
console.log(`\n[Server] Received request: ${req.method} http://${req.headers.host}${req.url}`);
const requestUrl = new URL(req.url, `http://${req.headers.host}`);
if (!isAuthorized(req, requestUrl)) {
res.writeHead(401, { 'Content-Type': 'application/json' });
return res.end(JSON.stringify({ error: { message: 'Unauthorized: API key is invalid or missing. Provide it in the `x-goog-api-key` header or as a `key` query parameter.' } }));
}
try {
const service = await getApiService();
if (req.method === 'GET' && requestUrl.pathname === '/v1beta/models') {
const models = await service.listModels();
res.writeHead(200, { 'Content-Type': 'application/json' });
const expiryDate = service.authClient.credentials.expiry_date;
console.log(`[Auth Token] Time until expiry: ${formatExpiryTime(expiryDate)}`);
return res.end(JSON.stringify(models));
}
const urlPattern = new RegExp(`/v1beta/models/(.+?):(${API_ACTIONS.GENERATE_CONTENT}|${API_ACTIONS.STREAM_GENERATE_CONTENT})`);
const urlMatch = requestUrl.pathname.match(urlPattern);
if (req.method === 'POST' && urlMatch) {
const [, model, action] = urlMatch;
const requestBody = await getRequestBody(req);
await manageSystemPrompt(requestBody); // Call the new function here
const promptText = extractPromptText(requestBody);
await logConversation('input', promptText, PROMPT_LOG_MODE, PROMPT_LOG_FILENAME);
if (action === API_ACTIONS.STREAM_GENERATE_CONTENT) {
await handleStreamRequest(res, service, model, requestBody);
} else {
await handleUnaryRequest(res, service, model, requestBody);
}
return;
}
res.writeHead(404, { 'Content-Type': 'application/json' });
return res.end(JSON.stringify({ error: { message: 'Not Found' } }));
} catch (error) {
handleError(res, error);
}
}
// --- Server Initialization ---
const server = http.createServer(requestHandler);
server.listen(SERVER_PORT, HOST, () => {
console.log(`--- Server Configuration ---`);
console.log(` Host: ${HOST}`);
console.log(` Port: ${SERVER_PORT}`);
console.log(` Required API Key: ${REQUIRED_API_KEY}`);
console.log(` Prompt Logging: ${PROMPT_LOG_MODE}${PROMPT_LOG_MODE === 'file' ? ` (to ${PROMPT_LOG_FILENAME})` : ''}`);
console.log(` OAuth Creds File Path: ${OAUTH_CREDS_FILE_PATH || 'Default'}`);
console.log(` Project ID: ${PROJECT_ID || 'Auto-discovered'}`); // Log the project ID
console.log(`--------------------------`);
console.log(`\nGemini API Server (Final) running on http://${HOST}:${SERVER_PORT}`);
console.log('Initializing service... This may take a moment.');
getApiService().catch(err => {
console.error("[Server] Pre-warming failed.", err.message);
});
});