- 将logPrompt重命名为logConversation以支持输入输出日志 - 添加manageSystemPrompt函数来管理系统提示文本文件 - 在流式和非流式请求中记录完整响应文本 - 改进提示文本提取逻辑以获取最新用户输入
551 lines
22 KiB
JavaScript
551 lines
22 KiB
JavaScript
/**
|
|
* @license
|
|
* Copyright 2025 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*
|
|
* 功能:
|
|
* 该脚本创建一个独立的 Node.js HTTP 服务器,作为 Google Cloud Code Assist API 的本地代理,
|
|
* 但它暴露了与 OpenAI API 兼容的接口,使其可以被任何支持 OpenAI API 的客户端直接使用。
|
|
*
|
|
* 主要特性:
|
|
* - **OpenAI API 兼容性**: 实现了 `/v1/models` 和 `/v1/chat/completions` 端点。
|
|
* - **格式转换**: 自动将 OpenAI 格式的请求/响应与内部 Gemini 格式进行转换。
|
|
* - **流式传输支持**: 完全支持 OpenAI 的流式响应 (`"stream": true`)。
|
|
* - **灵活的认证**: 支持通过 `Authorization: Bearer <key>` 请求头、URL 查询参数 (`?key=...`) 或 `x-goog-api-key` 请求头进行 API 密钥校验。
|
|
* - **全面且可控的日志系统**: 包括令牌剩余有效期、可输出到控制台或文件的带时间戳的提示词日志等。
|
|
* - **可配置性**: 可以通过命令行参数配置监听地址、端口、API 密钥和提示词日志模式。
|
|
* - **重用核心逻辑**: 底层依然使用 `gemini-core.js` 与 Google 服务通信。
|
|
*
|
|
* -----------------------------------------------------------------------------
|
|
* 使用说明 & 命令行示例
|
|
* -----------------------------------------------------------------------------
|
|
*
|
|
* 1. 环境设置:
|
|
* 在项目根目录创建一个 `package.json` 文件,内容为: `{"type": "module"}`,以避免模块类型警告。
|
|
* (此项目已提供 `package.json` 文件,无需手动创建)
|
|
*
|
|
* 2. 安装依赖:
|
|
* ```bash
|
|
* npm install
|
|
* ```
|
|
* 这将安装 `google-auth-library` 和 `uuid`。
|
|
*
|
|
* 3. 启动服务 (根据需要组合使用以下参数):
|
|
*
|
|
* - **默认启动**: 监听 `localhost:8000`
|
|
* ```bash
|
|
* node openai-api-server.js
|
|
* ```
|
|
* - **指定监听 IP** (位置参数):
|
|
* ```bash
|
|
* node openai-api-server.js 0.0.0.0
|
|
* ```
|
|
* - **使用命名参数指定端口**:
|
|
* ```bash
|
|
* node openai-api-server.js --port 8081
|
|
* ```
|
|
* - **使用命名参数指定 API Key**:
|
|
* ```bash
|
|
* node openai-api-server.js --api-key your_secret_key
|
|
* ```
|
|
* - **打印提示词到控制台**: 监听 `localhost`,并在控制台输出提示词详情
|
|
* ```bash
|
|
* node openai-api-server.js --log-prompts console
|
|
* ```
|
|
* - **打印提示词到文件**: 监听 `localhost`,并将提示词详情保存到一个带启动时间戳的新文件中 (例如: `prompts-20231027-153055.log`)
|
|
* ```bash
|
|
* node openai-api-server.js --log-prompts file
|
|
* ```
|
|
* - **组合使用参数** (参数顺序无关):
|
|
* ```bash
|
|
* node openai-api-server.js --port 8088 --api-key your_secret_key 0.0.0.0
|
|
* ```
|
|
*
|
|
* - **通过 base64 编码的凭证启动** (例如,用于 Docker 或 CI/CD 环境)
|
|
* ```bash
|
|
* node openai-api-server.js --oauth-creds-base64 "YOUR_BASE64_ENCODED_OAUTH_CREDS_JSON"
|
|
* ```
|
|
*
|
|
* - **通过指定凭证文件路径启动** (例如,用于自定义凭证位置)
|
|
* ```bash
|
|
* node openai-api-server.js --oauth-creds-file "/path/to/your/oauth_creds.json"
|
|
* ```
|
|
*
|
|
* - **通过指定项目ID启动** (例如,用于多项目环境)
|
|
* ```bash
|
|
* node openai-api-server.js --project-id your-gcp-project-id
|
|
* ```
|
|
*
|
|
* 4. 调用 API 接口 (假设 API Key: `your_secret_key`, 服务运行在 `localhost:8000`):
|
|
*
|
|
* - **a) 列出可用模型**
|
|
* ```bash
|
|
* curl http://localhost:8000/v1/models \
|
|
* -H "Authorization: Bearer your_secret_key"
|
|
* ```
|
|
* - **b) 生成内容 - 带系统提示词 (非流式)**
|
|
* ```bash
|
|
* curl http://localhost:8000/v1/chat/completions \
|
|
* -H "Content-Type: application/json" \
|
|
* -H "Authorization: Bearer your_secret_key" \
|
|
* -d '{
|
|
* "model": "gemini-2.5-pro",
|
|
* "messages": [
|
|
* {"role": "system", "content": "你是一只名叫 Neko 的猫。"},
|
|
* {"role": "user", "content": "你好,你叫什么名字?"}
|
|
* ]
|
|
* }'
|
|
* ```
|
|
* - **c) 生成内容 - 流式**
|
|
* ```bash
|
|
* curl http://localhost:8000/v1/chat/completions \
|
|
* -H "Content-Type: application/json" \
|
|
* -H "Authorization: Bearer your_secret_key" \
|
|
* -d '{
|
|
* "model": "gemini-2.5-flash",
|
|
* "messages": [
|
|
* {"role": "user", "content": "写一首关于宇宙的五行短诗"}
|
|
* ],
|
|
* "stream": true
|
|
* }'
|
|
*
|
|
*/
|
|
|
|
import * as http from 'http';
|
|
import { v4 as uuidv4 } from 'uuid';
|
|
import {
|
|
GeminiApiService,
|
|
API_ACTIONS,
|
|
formatExpiryTime,
|
|
logConversation, // Changed from logPrompt
|
|
extractPromptText,
|
|
getRequestBody,
|
|
extractResponseText,
|
|
manageSystemPrompt, // New import
|
|
} 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 = 8000; // 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] === '--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] === '--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] === '--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
|
|
|
|
// --- Format Conversion Functions ---
|
|
|
|
/**
|
|
* Extracts text from the 'content' field of an OpenAI message,
|
|
* which can be a string or an array of content parts (for multimodal input).
|
|
* @param {string|Array<Object>} content The content field from a message.
|
|
* @returns {string} The extracted text content.
|
|
*/
|
|
function extractTextFromMessageContent(content) {
|
|
if (typeof content === 'string') {
|
|
return content;
|
|
}
|
|
if (Array.isArray(content)) {
|
|
// Filter for text parts and join them. This gracefully handles multimodal inputs
|
|
// by only extracting the text, which is what the Gemini text models expect.
|
|
return content
|
|
.filter(part => part.type === 'text' && typeof part.text === 'string')
|
|
.map(part => part.text)
|
|
.join('\n');
|
|
}
|
|
// Return an empty string if content is not in a recognized format.
|
|
return '';
|
|
}
|
|
|
|
|
|
function toGeminiRequest(openaiRequest) {
|
|
const geminiRequest = {
|
|
contents: []
|
|
};
|
|
|
|
let systemContent = [];
|
|
const messages = openaiRequest.messages || [];
|
|
|
|
// 1. Extract and combine all system messages
|
|
const otherMessages = messages.filter(m => {
|
|
if (m.role === 'system') {
|
|
// Use the helper function to safely extract text from system messages
|
|
systemContent.push(extractTextFromMessageContent(m.content));
|
|
return false;
|
|
}
|
|
return true;
|
|
});
|
|
|
|
if (systemContent.length > 0) {
|
|
console.log('[Debug] systemContent before join:', systemContent);
|
|
geminiRequest.systemInstruction = {
|
|
parts: [{
|
|
// Now systemContent is an array of strings, so join is safe
|
|
text: systemContent.join('\n')
|
|
}]
|
|
};
|
|
}
|
|
|
|
// 2. Process the remaining messages, merging consecutive messages of the same role.
|
|
if (otherMessages.length > 0) {
|
|
let currentRole = null;
|
|
let currentContentParts = [];
|
|
|
|
for (const message of otherMessages) {
|
|
const role = message.role === 'assistant' ? 'model' : message.role;
|
|
|
|
if (role !== 'user' && role !== 'model') continue; // Ignore other roles
|
|
|
|
const messageText = extractTextFromMessageContent(message.content);
|
|
|
|
if (role === currentRole) {
|
|
// If the role is the same, append the content.
|
|
currentContentParts.push(messageText);
|
|
} else {
|
|
// If the role changes, push the previously accumulated content.
|
|
if (currentRole) {
|
|
console.log('[Debug] currentContentParts before join (in loop):', currentContentParts);
|
|
geminiRequest.contents.push({
|
|
role: currentRole,
|
|
parts: [{
|
|
text: currentContentParts.join('\n')
|
|
}]
|
|
});
|
|
}
|
|
// Start a new content block for the new role.
|
|
currentRole = role;
|
|
currentContentParts = [messageText];
|
|
}
|
|
}
|
|
|
|
// Push the last accumulated content block.
|
|
if (currentRole) {
|
|
console.log('[Debug] currentContentParts before join (at end):', currentContentParts);
|
|
geminiRequest.contents.push({
|
|
role: currentRole,
|
|
parts: [{
|
|
text: currentContentParts.join('\n')
|
|
}]
|
|
});
|
|
}
|
|
}
|
|
|
|
// 3. Basic validation and logging (the API will do the final validation)
|
|
if (geminiRequest.contents.length > 0) {
|
|
if (geminiRequest.contents[0].role !== 'user') {
|
|
console.warn("[Request Conversion] Warning: Conversation doesn't start with a 'user' role. The API will likely reject this request.");
|
|
}
|
|
if (geminiRequest.contents.length > 0 && geminiRequest.contents[geminiRequest.contents.length - 1].role !== 'user') {
|
|
console.warn("[Request Conversion] Warning: The last message in the conversation is not from the 'user'. The API may reject this request.");
|
|
}
|
|
}
|
|
|
|
|
|
console.log('[Server] Converted Gemini Request (before core processing):', JSON.stringify(geminiRequest, null, 2));
|
|
|
|
return geminiRequest;
|
|
}
|
|
|
|
function toOpenAIModelList(geminiModels) {
|
|
return {
|
|
object: "list",
|
|
data: geminiModels.map(modelId => ({
|
|
id: modelId,
|
|
object: "model",
|
|
created: Math.floor(Date.now() / 1000),
|
|
owned_by: "google",
|
|
})),
|
|
};
|
|
}
|
|
|
|
function toOpenAIChatCompletion(geminiResponse, model) {
|
|
const text = extractResponseText(geminiResponse);
|
|
return {
|
|
id: `chatcmpl-${uuidv4()}`,
|
|
object: "chat.completion",
|
|
created: Math.floor(Date.now() / 1000),
|
|
model: model,
|
|
choices: [{
|
|
index: 0,
|
|
message: {
|
|
role: "assistant",
|
|
content: text,
|
|
},
|
|
finish_reason: "stop",
|
|
}],
|
|
usage: geminiResponse.usageMetadata ? {
|
|
prompt_tokens: geminiResponse.usageMetadata.promptTokenCount || 0,
|
|
completion_tokens: geminiResponse.usageMetadata.candidatesTokenCount || 0,
|
|
total_tokens: geminiResponse.usageMetadata.totalTokenCount || 0,
|
|
} : {
|
|
prompt_tokens: 0,
|
|
completion_tokens: 0,
|
|
total_tokens: 0,
|
|
},
|
|
};
|
|
}
|
|
|
|
function toOpenAIStreamChunk(geminiChunk, model) {
|
|
const text = extractResponseText(geminiChunk);
|
|
return {
|
|
id: `chatcmpl-${uuidv4()}`,
|
|
object: "chat.completion.chunk",
|
|
created: Math.floor(Date.now() / 1000),
|
|
model: model,
|
|
choices: [{
|
|
index: 0,
|
|
delta: { content: text },
|
|
finish_reason: null,
|
|
}],
|
|
usage: geminiChunk.usageMetadata ? {
|
|
prompt_tokens: geminiChunk.usageMetadata.promptTokenCount || 0,
|
|
completion_tokens: geminiChunk.usageMetadata.candidatesTokenCount || 0,
|
|
total_tokens: geminiChunk.usageMetadata.totalTokenCount || 0,
|
|
} : {
|
|
prompt_tokens: 0,
|
|
completion_tokens: 0,
|
|
total_tokens: 0,
|
|
},
|
|
};
|
|
}
|
|
|
|
// --- Authorization ---
|
|
function isAuthorized(req, requestUrl) {
|
|
const authHeader = req.headers['authorization'];
|
|
const queryKey = requestUrl.searchParams.get('key');
|
|
const headerKey = req.headers['x-goog-api-key'];
|
|
|
|
// Check for Bearer token in Authorization header (OpenAI style)
|
|
if (authHeader && authHeader.startsWith('Bearer ')) {
|
|
const token = authHeader.substring(7);
|
|
if (token === REQUIRED_API_KEY) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// Check for API key in URL query parameter (Gemini style)
|
|
if (queryKey === REQUIRED_API_KEY) {
|
|
return true;
|
|
}
|
|
|
|
// Check for API key in x-goog-api-key header (Gemini style)
|
|
if (headerKey === REQUIRED_API_KEY) {
|
|
return true;
|
|
}
|
|
|
|
console.log(`[Auth] Unauthorized request denied. Bearer token: "${authHeader ? authHeader.substring(7) : 'N/A'}", 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) { // Ensure re-initialization if not already initialized
|
|
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" });
|
|
const stream = service.generateContentStream(model, requestBody);
|
|
console.log('[Server Response Stream]');
|
|
process.stdout.write('> ');
|
|
let fullResponseText = ''; // Declare fullResponseText here
|
|
try {
|
|
for await (const chunk of stream) {
|
|
const openAIChunk = toOpenAIStreamChunk(chunk, model);
|
|
const chunkText = openAIChunk.choices[0].delta.content || "";
|
|
if (chunkText) {
|
|
process.stdout.write(chunkText);
|
|
fullResponseText += chunkText; // Accumulate text here
|
|
}
|
|
res.write(`data: ${JSON.stringify(openAIChunk)}\n\n`);
|
|
}
|
|
// Send the final [DONE] message according to OpenAI spec
|
|
res.write('data: [DONE]\n\n');
|
|
} catch (error) {
|
|
console.error('\n[Server] Error during stream processing:', error.stack);
|
|
if (!res.writableEnded) {
|
|
// We may not be able to write headers, but we can try to send an error payload.
|
|
const errorPayload = { error: { message: "An error occurred during streaming.", details: error.message } };
|
|
res.end(JSON.stringify(errorPayload)); // End the response with an error
|
|
}
|
|
} finally {
|
|
process.stdout.write('\n');
|
|
if (!res.writableEnded) {
|
|
res.end();
|
|
}
|
|
// Log the full conversation here
|
|
await logConversation('output', fullResponseText, PROMPT_LOG_MODE, PROMPT_LOG_FILENAME);
|
|
}
|
|
const expiryDate = service.authClient.credentials.expiry_date;
|
|
console.log(`[Auth Token] Time until expiry: ${formatExpiryTime(expiryDate)}`);
|
|
}
|
|
|
|
async function handleUnaryRequest(res, service, model, requestBody) {
|
|
const geminiResponse = await service.generateContent(model, requestBody);
|
|
console.log('[Server] Raw Gemini Unary Response:', JSON.stringify(geminiResponse, null, 2)); // Add this line
|
|
const openAIResponse = toOpenAIChatCompletion(geminiResponse, model);
|
|
console.log('[Server Response Unary]');
|
|
process.stdout.write('> ');
|
|
const responseText = extractResponseText(geminiResponse);
|
|
process.stdout.write(responseText);
|
|
process.stdout.write('\n');
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify(openAIResponse));
|
|
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 `Authorization: Bearer <key>` header, as a `key` query parameter, or in the `x-goog-api-key` header.' } }));
|
|
}
|
|
|
|
try {
|
|
const service = await getApiService();
|
|
|
|
if (req.method === 'GET' && requestUrl.pathname === '/v1/models') {
|
|
const models = await service.listModels();
|
|
const openAIModels = toOpenAIModelList(models.models.map(m => m.name.replace('models/', '')));
|
|
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(openAIModels));
|
|
}
|
|
|
|
if (req.method === 'POST' && requestUrl.pathname === '/v1/chat/completions') {
|
|
const openaiRequest = await getRequestBody(req);
|
|
const model = openaiRequest.model;
|
|
const geminiRequest = toGeminiRequest(openaiRequest);
|
|
|
|
await manageSystemPrompt(geminiRequest); // Call the new function here
|
|
const promptText = extractPromptText(geminiRequest); // Use geminiRequest for logging
|
|
await logConversation('input', promptText, PROMPT_LOG_MODE, PROMPT_LOG_FILENAME);
|
|
|
|
if (openaiRequest.stream) {
|
|
await handleStreamRequest(res, service, model, geminiRequest);
|
|
} else {
|
|
await handleUnaryRequest(res, service, model, geminiRequest);
|
|
}
|
|
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(`--- OpenAI-Compatible 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(`\nServer running on http://${HOST}:${SERVER_PORT}`);
|
|
console.log('Initializing backend service... This may take a moment.');
|
|
getApiService().catch(err => {
|
|
console.error("[Server] Pre-warming failed.", err.message);
|
|
});
|
|
});
|