refactor: migrate to Sequelize ORM and improve project structure - Add Sequelize ORM for PostgreSQL - Restructure project into modular layers (routes, network, controller) - Add database initialization and models - Update server.js with graceful shutdown - Add comprehensive README
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@ -1,6 +1,8 @@
|
||||
# Created by https://www.toptal.com/developers/gitignore/api/node
|
||||
# Edit at https://www.toptal.com/developers/gitignore?templates=node
|
||||
|
||||
.cursor*
|
||||
|
||||
### Node ###
|
||||
# Logs
|
||||
logs
|
||||
|
116
README.md
Normal file
116
README.md
Normal file
@ -0,0 +1,116 @@
|
||||
# AI API Server
|
||||
|
||||
A Node.js/Express.js backend server that proxies requests to Ollama and provides logging and monitoring capabilities.
|
||||
|
||||
## Features
|
||||
|
||||
- Proxies requests to Ollama (running locally at http://localhost:11434)
|
||||
- Supports both chat and generate endpoints
|
||||
- Streaming responses for real-time AI interactions
|
||||
- PostgreSQL database for logging prompts and errors
|
||||
- API key authentication
|
||||
- Modular, maintainable codebase
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js 14+ and npm
|
||||
- PostgreSQL 12+
|
||||
- Ollama running locally (http://localhost:11434)
|
||||
|
||||
## Setup
|
||||
|
||||
1. Clone the repository
|
||||
2. Install dependencies:
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
3. Create a `.env` file with the following variables:
|
||||
```
|
||||
PORT=5000
|
||||
NODE_ENV=development
|
||||
API_KEY=your_api_key_here
|
||||
DB_HOST=localhost
|
||||
DB_PORT=5432
|
||||
DB_NAME=ai_db
|
||||
DB_USER=your_db_user
|
||||
DB_PASSWORD=your_db_password
|
||||
```
|
||||
4. Create the database:
|
||||
```bash
|
||||
createdb ai_db
|
||||
```
|
||||
5. Run the database migrations:
|
||||
```bash
|
||||
psql -d ai_db -f src/database/schema.sql
|
||||
```
|
||||
6. Start the server:
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Generate/Chat
|
||||
|
||||
- `POST /api/generate`
|
||||
- Supports both chat-style (messages array) and prompt-style requests
|
||||
- Requires API key in headers (`api-key` or `Authorization: Bearer <key>`)
|
||||
- Example request:
|
||||
```json
|
||||
{
|
||||
"model": "codellama:7b",
|
||||
"messages": [
|
||||
{"role": "user", "content": "Hello, how are you?"}
|
||||
],
|
||||
"stream": true
|
||||
}
|
||||
```
|
||||
|
||||
### Logs
|
||||
|
||||
- `GET /api/errors/prompts`
|
||||
- Returns latest 100 prompts
|
||||
- Query parameter: `limit` (default: 100)
|
||||
- Requires API key
|
||||
|
||||
- `GET /api/errors/logs`
|
||||
- Returns latest 100 error logs
|
||||
- Query parameter: `limit` (default: 100)
|
||||
- Requires API key
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── config/ # Configuration files
|
||||
├── controllers/ # Request handlers
|
||||
├── database/ # Database schema and migrations
|
||||
├── middleware/ # Express middleware
|
||||
├── models/ # Database models
|
||||
├── network/ # External service communication
|
||||
├── routes/ # API routes
|
||||
└── server.js # Application entry point
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
- All errors are logged to the database
|
||||
- API errors return appropriate HTTP status codes
|
||||
- Streaming errors are handled gracefully
|
||||
|
||||
## Security
|
||||
|
||||
- API key authentication required for all endpoints
|
||||
- Environment variables for sensitive data
|
||||
- Input validation and sanitization
|
||||
- Secure headers with Helmet (TODO)
|
||||
|
||||
## Development
|
||||
|
||||
- Use `npm run dev` for development with auto-reload
|
||||
- Use `npm test` to run tests (TODO)
|
||||
- Follow the existing code style and structure
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
4218
package-lock.json
generated
4218
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
21
package.json
21
package.json
@ -5,20 +5,27 @@
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"start": "nodemon server.js",
|
||||
"start:server": "pm2 start server.js --name \"my-server\" --env production"
|
||||
"start": "nodemon src/server.js",
|
||||
"start:server": "pm2 start src/server.js --name \"my-server\" --env production"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "Carlos Gutierrez <ingecarlos.gutierrez@gmail.com>",
|
||||
"license": "MIT",
|
||||
"type": "commonjs",
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.1.9"
|
||||
"jest": "^29.7.0",
|
||||
"nodemon": "^3.1.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.8.4",
|
||||
"body-parser": "^2.2.0",
|
||||
"dotenv": "^16.4.7",
|
||||
"express": "^5.1.0"
|
||||
"axios": "^1.6.7",
|
||||
"body-parser": "^1.20.2",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.18.2",
|
||||
"pg": "^8.16.0",
|
||||
"pg-hstore": "^2.3.4",
|
||||
"sequelize": "^6.37.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
}
|
||||
|
265
server.js
265
server.js
@ -1,265 +0,0 @@
|
||||
require("dotenv").config(); // Load environment variables from .env file
|
||||
const express = require("express");
|
||||
const axios = require("axios");
|
||||
const bodyParser = require("body-parser");
|
||||
|
||||
const app = express();
|
||||
const port = 5000; // Backend server will run on this port
|
||||
|
||||
// Middleware to parse JSON request bodies
|
||||
app.use(bodyParser.json());
|
||||
|
||||
// API Key validation middleware
|
||||
const validateApiKey = (req, res, next) => {
|
||||
const apiKey = req.headers["api-key"];
|
||||
const authHeader = req.headers["authorization"];
|
||||
|
||||
// Try to extract token from Authorization: Bearer <token>
|
||||
let token = null;
|
||||
if (authHeader && authHeader.startsWith("Bearer ")) {
|
||||
token = authHeader.split(" ")[1];
|
||||
}
|
||||
|
||||
const providedKey = apiKey || token;
|
||||
|
||||
if (!providedKey) {
|
||||
return res.status(400).json({ error: "API key is missing" });
|
||||
}
|
||||
|
||||
if (providedKey !== process.env.API_KEY) {
|
||||
return res.status(403).json({ error: "Invalid API key" });
|
||||
}
|
||||
|
||||
next(); // Proceed if the API key is valid
|
||||
};
|
||||
|
||||
app.get("/", (req, res) => {
|
||||
res.send("Hello from the backend server!");
|
||||
})
|
||||
|
||||
// Forward request to localhost:11434 (ollama)
|
||||
|
||||
app.post("/api/generate/api/chat", validateApiKey, async (req, res) => {
|
||||
try {
|
||||
const { model, messages, system } = req.body;
|
||||
console.log(req.headers);
|
||||
|
||||
const prompt = messages
|
||||
.map(
|
||||
(msg) =>
|
||||
`${msg.role === "system" ? "" : msg.role + ": "}${msg.content}`,
|
||||
)
|
||||
.join("\n");
|
||||
|
||||
console.log("🧠 Prompt for Ollama:\n", prompt);
|
||||
|
||||
const response = await axios.post(
|
||||
"http://localhost:11434/api/generate",
|
||||
{
|
||||
model: model || "deepseek-r1:latest",
|
||||
prompt,
|
||||
stream: true,
|
||||
},
|
||||
{ responseType: "stream" },
|
||||
);
|
||||
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.setHeader("Transfer-Encoding", "chunked");
|
||||
|
||||
let insideThink = false;
|
||||
|
||||
response.data.on("data", (chunk) => {
|
||||
const lines = chunk.toString("utf8").split("\n").filter(Boolean);
|
||||
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const json = JSON.parse(line);
|
||||
const text = json.response;
|
||||
|
||||
if (text?.includes("<think>")) {
|
||||
insideThink = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (text?.includes("</think>")) {
|
||||
insideThink = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!insideThink && text) {
|
||||
const responseLine = JSON.stringify({
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: text,
|
||||
},
|
||||
done: false,
|
||||
});
|
||||
res.write(responseLine + "\n");
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("Chunk parse failed:", err);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
response.data.on("end", () => {
|
||||
res.write(JSON.stringify({ done: true }) + "\n");
|
||||
res.end();
|
||||
});
|
||||
|
||||
response.data.on("error", (err) => {
|
||||
console.error("Ollama stream error:", err);
|
||||
res.write(
|
||||
JSON.stringify({ error: "Stream error", message: err.message }) + "\n",
|
||||
);
|
||||
res.end();
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"❌ Error communicating with Ollama:",
|
||||
error.response?.data || error.message,
|
||||
);
|
||||
res
|
||||
.status(500)
|
||||
.json({ error: "Internal Server Error", message: error.message });
|
||||
}
|
||||
});
|
||||
// Forward request to localhost:11434 (ollama)
|
||||
// app.post("/api/generate", validateApiKey, async (req, res) => {
|
||||
// try {
|
||||
// // Forwarding the request to localhost:11434 with the prompt
|
||||
// const authHeader = req.headers["authorization"];
|
||||
// console.log("Authorization header:", authHeader);
|
||||
// console.log("checking api", apiKey !== process.env.API_KEY);
|
||||
// console.log("Body: ", req.body);
|
||||
// const response = await axios.post(
|
||||
// "http://localhost:11434/api/generate",
|
||||
// req.body,
|
||||
// );
|
||||
//
|
||||
// // Send the response from localhost:11434 back to the client
|
||||
// res.status(response.status).json(response.data);
|
||||
// } catch (error) {
|
||||
// // Enhanced error logging
|
||||
// console.error(
|
||||
// "Error forwarding request to localhost:11434:",
|
||||
// error.response ? error.response.data : error.message,
|
||||
// );
|
||||
// res
|
||||
// .status(500)
|
||||
// .json({ error: "Internal Server Error", message: error.message });
|
||||
// }
|
||||
// });
|
||||
|
||||
app.post("/api/generate", validateApiKey, async (req, res) => {
|
||||
try {
|
||||
const requestBody = req.body;
|
||||
console.log("Request to /api/generate. Body:", JSON.stringify(requestBody, null, 2));
|
||||
// console.log("Headers:", JSON.stringify(req.headers, null, 2)); // For more detailed debugging if needed
|
||||
|
||||
let ollamaEndpointUrl;
|
||||
let payloadForOllama = { ...requestBody }; // Start with a copy of the incoming body
|
||||
|
||||
// Ensure the model from avante.nvim config is respected or default if not provided in body
|
||||
if (!payloadForOllama.model && req.nvim_config_model) { // Assuming you might pass this if needed
|
||||
payloadForOllama.model = req.nvim_config_model; // Example: "codellama:7b"
|
||||
} else if (!payloadForOllama.model) {
|
||||
payloadForOllama.model = "codellama:7b"; // Fallback model
|
||||
}
|
||||
|
||||
|
||||
// Determine if this is a chat-style request or generate-style
|
||||
// avante.nvim (inheriting from ollama) might send a body for /api/chat or /api/generate
|
||||
if (requestBody.messages && Array.isArray(requestBody.messages)) {
|
||||
ollamaEndpointUrl = "http://localhost:11434/api/chat";
|
||||
// Payload for /api/chat typically includes: model, messages, stream, options, format, keep_alive
|
||||
// Ensure essential fields are present if not already in requestBody
|
||||
payloadForOllama.stream = requestBody.stream !== undefined ? requestBody.stream : true;
|
||||
console.log(`Proxying to Ollama /api/chat with model ${payloadForOllama.model}`);
|
||||
} else if (requestBody.prompt) {
|
||||
ollamaEndpointUrl = "http://localhost:11434/api/generate";
|
||||
// Payload for /api/generate typically includes: model, prompt, system, stream, context, options, format, keep_alive
|
||||
// Ensure essential fields are present
|
||||
payloadForOllama.stream = requestBody.stream !== undefined ? requestBody.stream : true;
|
||||
console.log(`Proxying to Ollama /api/generate with model ${payloadForOllama.model}`);
|
||||
} else {
|
||||
console.error("Invalid request body: missing 'messages' or 'prompt'", requestBody);
|
||||
return res.status(400).json({ error: "Invalid request body: must contain 'messages' array or 'prompt' string" });
|
||||
}
|
||||
|
||||
if (payloadForOllama.stream) {
|
||||
const ollamaResponse = await axios.post(
|
||||
ollamaEndpointUrl,
|
||||
payloadForOllama,
|
||||
{ responseType: "stream" } // Crucial for getting a stream from Axios
|
||||
);
|
||||
|
||||
// Set headers for streaming newline-delimited JSON (Ollama's stream format)
|
||||
res.setHeader("Content-Type", "application/x-ndjson");
|
||||
res.setHeader("Transfer-Encoding", "chunked");
|
||||
|
||||
// Pipe the stream from Ollama directly to the client (avante.nvim)
|
||||
ollamaResponse.data.pipe(res);
|
||||
|
||||
ollamaResponse.data.on('error', (err) => {
|
||||
console.error(`Ollama stream error for ${ollamaEndpointUrl}:`, err.message);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: "Ollama Stream Error", message: err.message });
|
||||
} else if (!res.writableEnded) {
|
||||
res.end(); // End the response if headers already sent and stream is not yet ended
|
||||
}
|
||||
});
|
||||
|
||||
ollamaResponse.data.on('end', () => {
|
||||
if (!res.writableEnded) {
|
||||
res.end(); // Ensure response is ended when Ollama stream finishes
|
||||
}
|
||||
});
|
||||
|
||||
} else {
|
||||
// Non-streaming request (less common for interactive LLM use)
|
||||
const ollamaResponse = await axios.post(
|
||||
ollamaEndpointUrl,
|
||||
payloadForOllama
|
||||
);
|
||||
res.status(ollamaResponse.status).json(ollamaResponse.data);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
let errorMessage = error.message;
|
||||
let errorData = null;
|
||||
let statusCode = 500;
|
||||
|
||||
if (error.response) { // Error from Axios request to Ollama
|
||||
statusCode = error.response.status || 500;
|
||||
errorMessage = error.response.statusText || "Error communicating with Ollama";
|
||||
errorData = error.response.data;
|
||||
console.error(
|
||||
`Error proxying to Ollama (${error.config?.url || 'N/A'}) with status ${statusCode}:`,
|
||||
typeof errorData === 'string' || Buffer.isBuffer(errorData) ? errorData.toString() : errorData || errorMessage
|
||||
);
|
||||
} else if (error.request) { // No response received from Ollama
|
||||
console.error("Error proxying to Ollama: No response received", error.request);
|
||||
errorMessage = "No response from Ollama service";
|
||||
} else { // Other errors
|
||||
console.error("Error setting up proxy request to Ollama:", error.message);
|
||||
}
|
||||
|
||||
if (!res.headersSent) {
|
||||
res.status(statusCode).json({ error: "Internal Server Error", message: errorMessage, details: errorData });
|
||||
} else if (!res.writableEnded) {
|
||||
res.end(); // Ensure the response is closed if an error occurs after starting to stream
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// It's advisable to remove or disable the old `/api/generate/api/chat` endpoint
|
||||
// if `/api/generate` now correctly handles both Ollama's /api/chat and /api/generate requests.
|
||||
// This avoids confusion and ensures avante.nvim (configured for `/api/generate`) hits the right logic.
|
||||
// For example, comment out:
|
||||
// app.post("/api/generate/api/chat", validateApiKey, async (req, res) => { ... });
|
||||
|
||||
// Start the server
|
||||
app.listen(port, () => {
|
||||
console.log(`Server running on http://localhost:${port}`);
|
||||
});
|
18
src/api/controller/generate.js
Normal file
18
src/api/controller/generate.js
Normal file
@ -0,0 +1,18 @@
|
||||
const Prompt = require('../../models/Prompt');
|
||||
const ErrorLog = require('../../models/Error');
|
||||
|
||||
/**
|
||||
* Save a prompt to the database
|
||||
*/
|
||||
async function savePrompt({ model, prompt, messages }) {
|
||||
return Prompt.createPrompt({ model, prompt, messages });
|
||||
}
|
||||
|
||||
/**
|
||||
* Save an error to the database
|
||||
*/
|
||||
async function saveError({ error_message, details }) {
|
||||
return ErrorLog.createError({ error_message, details });
|
||||
}
|
||||
|
||||
module.exports = { savePrompt, saveError };
|
12
src/api/controller/logs.js
Normal file
12
src/api/controller/logs.js
Normal file
@ -0,0 +1,12 @@
|
||||
const Prompt = require('../../models/Prompt');
|
||||
const ErrorLog = require('../../models/Error');
|
||||
|
||||
async function fetchPrompts(limit = 100) {
|
||||
return Prompt.getLatest(limit);
|
||||
}
|
||||
|
||||
async function fetchErrors(limit = 100) {
|
||||
return ErrorLog.getLatest(limit);
|
||||
}
|
||||
|
||||
module.exports = { fetchPrompts, fetchErrors };
|
61
src/api/network/generate.js
Normal file
61
src/api/network/generate.js
Normal file
@ -0,0 +1,61 @@
|
||||
const axios = require('axios');
|
||||
const { savePrompt, saveError } = require('../controller/generate');
|
||||
|
||||
/**
|
||||
* Express handler for /api/generate
|
||||
*/
|
||||
async function handleGenerate(req, res) {
|
||||
const requestBody = req.body;
|
||||
try {
|
||||
// Determine if this is a chat-style request or generate-style
|
||||
const isChatRequest = requestBody.messages && Array.isArray(requestBody.messages);
|
||||
const model = requestBody.model || 'codellama:7b';
|
||||
const stream = requestBody.stream !== undefined ? requestBody.stream : true;
|
||||
|
||||
// Save the prompt to database
|
||||
await savePrompt({
|
||||
model,
|
||||
prompt: isChatRequest ? null : requestBody.prompt,
|
||||
messages: isChatRequest ? requestBody.messages : null
|
||||
});
|
||||
|
||||
// Prepare Ollama endpoint and payload
|
||||
const ollamaUrl = isChatRequest
|
||||
? 'http://localhost:11434/api/chat'
|
||||
: 'http://localhost:11434/api/generate';
|
||||
|
||||
const payload = isChatRequest
|
||||
? { model, messages: requestBody.messages, stream }
|
||||
: { model, prompt: requestBody.prompt, stream };
|
||||
|
||||
if (stream) {
|
||||
const ollamaResponse = await axios.post(ollamaUrl, payload, { responseType: 'stream' });
|
||||
res.setHeader('Content-Type', 'application/x-ndjson');
|
||||
res.setHeader('Transfer-Encoding', 'chunked');
|
||||
ollamaResponse.data.pipe(res);
|
||||
ollamaResponse.data.on('error', async (err) => {
|
||||
await saveError({ error_message: err.message, details: { stack: err.stack } });
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: 'Ollama Stream Error', message: err.message });
|
||||
} else if (!res.writableEnded) {
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
ollamaResponse.data.on('end', () => {
|
||||
if (!res.writableEnded) res.end();
|
||||
});
|
||||
} else {
|
||||
const ollamaResponse = await axios.post(ollamaUrl, payload);
|
||||
res.status(ollamaResponse.status).json(ollamaResponse.data);
|
||||
}
|
||||
} catch (error) {
|
||||
await saveError({ error_message: error.message, details: error.response?.data || error.stack });
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: 'Internal Server Error', message: error.message });
|
||||
} else if (!res.writableEnded) {
|
||||
res.end();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { handleGenerate };
|
23
src/api/network/logs.js
Normal file
23
src/api/network/logs.js
Normal file
@ -0,0 +1,23 @@
|
||||
const { fetchPrompts, fetchErrors } = require('../controller/logs');
|
||||
|
||||
async function getLatestPrompts(req, res) {
|
||||
try {
|
||||
const limit = parseInt(req.query.limit) || 100;
|
||||
const prompts = await fetchPrompts(limit);
|
||||
res.json({ success: true, data: prompts });
|
||||
} catch (error) {
|
||||
res.status(500).json({ success: false, error: 'Internal Server Error', message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
async function getLatestErrors(req, res) {
|
||||
try {
|
||||
const limit = parseInt(req.query.limit) || 100;
|
||||
const errors = await fetchErrors(limit);
|
||||
res.json({ success: true, data: errors });
|
||||
} catch (error) {
|
||||
res.status(500).json({ success: false, error: 'Internal Server Error', message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { getLatestPrompts, getLatestErrors };
|
32
src/config/database.js
Normal file
32
src/config/database.js
Normal file
@ -0,0 +1,32 @@
|
||||
const { Sequelize } = require('sequelize');
|
||||
require('dotenv').config();
|
||||
|
||||
const sequelize = new Sequelize(
|
||||
process.env.DB_NAME || 'ai_db',
|
||||
process.env.DB_USER || 'carlos',
|
||||
process.env.DB_PASSWORD || 'supersecretpassword',
|
||||
{
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
port: process.env.DB_PORT || 5432,
|
||||
dialect: 'postgres',
|
||||
logging: process.env.NODE_ENV === 'development' ? console.log : false,
|
||||
pool: {
|
||||
max: 20,
|
||||
min: 0,
|
||||
acquire: 30000,
|
||||
idle: 10000
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Test the connection
|
||||
sequelize.authenticate()
|
||||
.then(() => {
|
||||
console.log('📦 Connected to PostgreSQL database');
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('❌ Unable to connect to the database:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
module.exports = sequelize;
|
87
src/controllers/generateController.js
Normal file
87
src/controllers/generateController.js
Normal file
@ -0,0 +1,87 @@
|
||||
const OllamaService = require('../network/ollama');
|
||||
const Prompt = require('../models/Prompt');
|
||||
const ErrorLog = require('../models/Error');
|
||||
|
||||
class GenerateController {
|
||||
/**
|
||||
* Handle generate/chat requests
|
||||
* @param {Object} req - Express request object
|
||||
* @param {Object} res - Express response object
|
||||
*/
|
||||
static async handleRequest(req, res) {
|
||||
const requestBody = req.body;
|
||||
console.log('Request to /api/generate:', JSON.stringify(requestBody, null, 2));
|
||||
|
||||
try {
|
||||
// Determine if this is a chat-style request or generate-style
|
||||
const isChatRequest = requestBody.messages && Array.isArray(requestBody.messages);
|
||||
const model = requestBody.model || 'codellama:7b';
|
||||
const stream = requestBody.stream !== undefined ? requestBody.stream : true;
|
||||
|
||||
// Save the prompt to database
|
||||
await Prompt.create({
|
||||
model,
|
||||
prompt: isChatRequest ? null : requestBody.prompt,
|
||||
messages: isChatRequest ? requestBody.messages : null
|
||||
});
|
||||
|
||||
// Make request to Ollama
|
||||
const ollamaResponse = isChatRequest
|
||||
? await OllamaService.chat({ model, messages: requestBody.messages, stream })
|
||||
: await OllamaService.generate({ model, prompt: requestBody.prompt, stream });
|
||||
|
||||
if (stream) {
|
||||
OllamaService.processStream(ollamaResponse, res, async (error) => {
|
||||
await this.handleError(error, res);
|
||||
});
|
||||
} else {
|
||||
res.json(ollamaResponse.data);
|
||||
}
|
||||
} catch (error) {
|
||||
await this.handleError(error, res);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle errors and save them to database
|
||||
* @param {Error} error - The error object
|
||||
* @param {Object} res - Express response object
|
||||
*/
|
||||
static async handleError(error, res) {
|
||||
let errorMessage = error.message;
|
||||
let errorDetails = null;
|
||||
let statusCode = 500;
|
||||
|
||||
if (error.response) {
|
||||
statusCode = error.response.status || 500;
|
||||
errorMessage = error.response.statusText || 'Error communicating with Ollama';
|
||||
errorDetails = error.response.data;
|
||||
} else if (error.request) {
|
||||
errorMessage = 'No response from Ollama service';
|
||||
errorDetails = { request: error.request };
|
||||
}
|
||||
|
||||
// Save error to database
|
||||
try {
|
||||
await ErrorLog.create({
|
||||
error_message: errorMessage,
|
||||
details: errorDetails
|
||||
});
|
||||
} catch (dbError) {
|
||||
console.error('Failed to save error log:', dbError);
|
||||
}
|
||||
|
||||
if (!res.headersSent) {
|
||||
res.status(statusCode).json({
|
||||
success: false,
|
||||
error: 'Internal Server Error',
|
||||
message: errorMessage,
|
||||
details: errorDetails
|
||||
});
|
||||
} else if (!res.writableEnded) {
|
||||
res.end();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = GenerateController;
|
54
src/controllers/logsController.js
Normal file
54
src/controllers/logsController.js
Normal file
@ -0,0 +1,54 @@
|
||||
const Prompt = require('../models/Prompt');
|
||||
const ErrorLog = require('../models/Error');
|
||||
|
||||
class LogsController {
|
||||
/**
|
||||
* Get latest prompts
|
||||
* @param {Object} req - Express request object
|
||||
* @param {Object} res - Express response object
|
||||
*/
|
||||
static async getLatestPrompts(req, res) {
|
||||
try {
|
||||
const limit = parseInt(req.query.limit) || 100;
|
||||
const prompts = await Prompt.getLatest(limit);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: prompts
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching prompts:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Internal Server Error',
|
||||
message: 'Failed to fetch prompts'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get latest error logs
|
||||
* @param {Object} req - Express request object
|
||||
* @param {Object} res - Express response object
|
||||
*/
|
||||
static async getLatestErrors(req, res) {
|
||||
try {
|
||||
const limit = parseInt(req.query.limit) || 100;
|
||||
const errors = await ErrorLog.getLatest(limit);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: errors
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching error logs:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Internal Server Error',
|
||||
message: 'Failed to fetch error logs'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = LogsController;
|
20
src/database/init.js
Normal file
20
src/database/init.js
Normal file
@ -0,0 +1,20 @@
|
||||
const sequelize = require('../config/database');
|
||||
const Prompt = require('../models/Prompt');
|
||||
const ErrorLog = require('../models/Error');
|
||||
|
||||
/**
|
||||
* Initialize database and sync models
|
||||
*/
|
||||
async function initDatabase() {
|
||||
try {
|
||||
// Sync all models with database
|
||||
// force: false means don't drop tables if they exist
|
||||
await sequelize.sync({ force: false });
|
||||
console.log('✅ Database synchronized successfully');
|
||||
} catch (error) {
|
||||
console.error('❌ Error synchronizing database:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = initDatabase;
|
20
src/database/schema.sql
Normal file
20
src/database/schema.sql
Normal file
@ -0,0 +1,20 @@
|
||||
-- Create prompts table
|
||||
CREATE TABLE IF NOT EXISTS prompts (
|
||||
id SERIAL PRIMARY KEY,
|
||||
model VARCHAR(100) NOT NULL,
|
||||
prompt TEXT,
|
||||
messages JSONB,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Create errors table
|
||||
CREATE TABLE IF NOT EXISTS errors (
|
||||
id SERIAL PRIMARY KEY,
|
||||
error_message TEXT NOT NULL,
|
||||
details JSONB,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Create indexes for better query performance
|
||||
CREATE INDEX IF NOT EXISTS idx_prompts_created_at ON prompts(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_errors_created_at ON errors(created_at DESC);
|
38
src/middleware/auth.js
Normal file
38
src/middleware/auth.js
Normal file
@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Middleware to validate API key from headers
|
||||
* Supports both 'api-key' header and 'Authorization: Bearer <token>'
|
||||
*/
|
||||
const validateApiKey = (req, res, next) => {
|
||||
const apiKey = req.headers['api-key'];
|
||||
const authHeader = req.headers['authorization'];
|
||||
|
||||
// Try to extract token from Authorization: Bearer <token>
|
||||
let token = null;
|
||||
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||
token = authHeader.split(' ')[1];
|
||||
}
|
||||
|
||||
const providedKey = apiKey || token;
|
||||
|
||||
if (!providedKey) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: 'Authentication required',
|
||||
message: 'API key is missing'
|
||||
});
|
||||
}
|
||||
|
||||
if (providedKey !== process.env.API_KEY) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
error: 'Authentication failed',
|
||||
message: 'Invalid API key'
|
||||
});
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
validateApiKey
|
||||
};
|
55
src/models/Error.js
Normal file
55
src/models/Error.js
Normal file
@ -0,0 +1,55 @@
|
||||
const { DataTypes } = require('sequelize');
|
||||
const sequelize = require('../config/database');
|
||||
|
||||
const ErrorLog = sequelize.define('ErrorLog', {
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true
|
||||
},
|
||||
error_message: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: false
|
||||
},
|
||||
details: {
|
||||
type: DataTypes.JSONB,
|
||||
allowNull: true
|
||||
},
|
||||
created_at: {
|
||||
type: DataTypes.DATE,
|
||||
defaultValue: DataTypes.NOW
|
||||
}
|
||||
}, {
|
||||
tableName: 'errors',
|
||||
timestamps: false,
|
||||
indexes: [
|
||||
{
|
||||
fields: ['created_at']
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
/**
|
||||
* Create a new error log record
|
||||
* @param {Object} data - The error data
|
||||
* @param {string} data.error_message - The error message
|
||||
* @param {Object} [data.details] - Additional error details as JSON
|
||||
* @returns {Promise<Object>} The created error record
|
||||
*/
|
||||
ErrorLog.createError = async function({ error_message, details }) {
|
||||
return this.create({ error_message, details });
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the latest error logs
|
||||
* @param {number} limit - Maximum number of records to return
|
||||
* @returns {Promise<Array>} Array of error records
|
||||
*/
|
||||
ErrorLog.getLatest = async function(limit = 100) {
|
||||
return this.findAll({
|
||||
order: [['created_at', 'DESC']],
|
||||
limit
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = ErrorLog;
|
60
src/models/Prompt.js
Normal file
60
src/models/Prompt.js
Normal file
@ -0,0 +1,60 @@
|
||||
const { DataTypes } = require('sequelize');
|
||||
const sequelize = require('../config/database');
|
||||
|
||||
const Prompt = sequelize.define('Prompt', {
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true
|
||||
},
|
||||
model: {
|
||||
type: DataTypes.STRING(100),
|
||||
allowNull: false
|
||||
},
|
||||
prompt: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true
|
||||
},
|
||||
messages: {
|
||||
type: DataTypes.JSONB,
|
||||
allowNull: true
|
||||
},
|
||||
created_at: {
|
||||
type: DataTypes.DATE,
|
||||
defaultValue: DataTypes.NOW
|
||||
}
|
||||
}, {
|
||||
tableName: 'prompts',
|
||||
timestamps: false,
|
||||
indexes: [
|
||||
{
|
||||
fields: ['created_at']
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
/**
|
||||
* Create a new prompt record
|
||||
* @param {Object} data - The prompt data
|
||||
* @param {string} data.model - The model name
|
||||
* @param {string} [data.prompt] - The prompt text (for non-chat requests)
|
||||
* @param {Array} [data.messages] - The messages array (for chat requests)
|
||||
* @returns {Promise<Object>} The created prompt record
|
||||
*/
|
||||
Prompt.createPrompt = async function({ model, prompt, messages }) {
|
||||
return this.create({ model, prompt, messages });
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the latest prompts
|
||||
* @param {number} limit - Maximum number of records to return
|
||||
* @returns {Promise<Array>} Array of prompt records
|
||||
*/
|
||||
Prompt.getLatest = async function(limit = 100) {
|
||||
return this.findAll({
|
||||
order: [['created_at', 'DESC']],
|
||||
limit
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = Prompt;
|
116
src/network/ollama.js
Normal file
116
src/network/ollama.js
Normal file
@ -0,0 +1,116 @@
|
||||
const axios = require('axios');
|
||||
|
||||
const OLLAMA_BASE_URL = 'http://localhost:11434';
|
||||
|
||||
class OllamaService {
|
||||
/**
|
||||
* Generate a response from Ollama using the chat endpoint
|
||||
* @param {Object} options - The request options
|
||||
* @param {string} options.model - The model to use
|
||||
* @param {Array} options.messages - The chat messages
|
||||
* @param {boolean} [options.stream=true] - Whether to stream the response
|
||||
* @returns {Promise<Object>} The Ollama response
|
||||
*/
|
||||
static async chat({ model, messages, stream = true }) {
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`${OLLAMA_BASE_URL}/api/chat`,
|
||||
{
|
||||
model,
|
||||
messages,
|
||||
stream
|
||||
},
|
||||
stream ? { responseType: 'stream' } : {}
|
||||
);
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('Ollama chat error:', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a response from Ollama using the generate endpoint
|
||||
* @param {Object} options - The request options
|
||||
* @param {string} options.model - The model to use
|
||||
* @param {string} options.prompt - The prompt text
|
||||
* @param {boolean} [options.stream=true] - Whether to stream the response
|
||||
* @returns {Promise<Object>} The Ollama response
|
||||
*/
|
||||
static async generate({ model, prompt, stream = true }) {
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`${OLLAMA_BASE_URL}/api/generate`,
|
||||
{
|
||||
model,
|
||||
prompt,
|
||||
stream
|
||||
},
|
||||
stream ? { responseType: 'stream' } : {}
|
||||
);
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('Ollama generate error:', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a streaming response from Ollama
|
||||
* @param {Object} response - The Axios response object
|
||||
* @param {Object} res - The Express response object
|
||||
* @param {Function} onError - Error callback function
|
||||
*/
|
||||
static processStream(response, res, onError) {
|
||||
res.setHeader('Content-Type', 'application/x-ndjson');
|
||||
res.setHeader('Transfer-Encoding', 'chunked');
|
||||
|
||||
let insideThink = false;
|
||||
|
||||
response.data.on('data', (chunk) => {
|
||||
const lines = chunk.toString('utf8').split('\n').filter(Boolean);
|
||||
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const json = JSON.parse(line);
|
||||
const text = json.response;
|
||||
|
||||
if (text?.includes('<think>')) {
|
||||
insideThink = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (text?.includes('</think>')) {
|
||||
insideThink = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!insideThink && text) {
|
||||
const responseLine = JSON.stringify({
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: text
|
||||
},
|
||||
done: false
|
||||
});
|
||||
res.write(responseLine + '\n');
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Chunk parse failed:', err);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
response.data.on('end', () => {
|
||||
res.write(JSON.stringify({ done: true }) + '\n');
|
||||
res.end();
|
||||
});
|
||||
|
||||
response.data.on('error', (err) => {
|
||||
console.error('Ollama stream error:', err);
|
||||
onError(err);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = OllamaService;
|
12
src/routes/generate.js
Normal file
12
src/routes/generate.js
Normal file
@ -0,0 +1,12 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { validateApiKey } = require('../middleware/auth');
|
||||
const { handleGenerate } = require('../api/network/generate');
|
||||
|
||||
// Apply API key validation to all routes
|
||||
router.use(validateApiKey);
|
||||
|
||||
// Handle generate/chat requests
|
||||
router.post('/', handleGenerate);
|
||||
|
||||
module.exports = router;
|
15
src/routes/logs.js
Normal file
15
src/routes/logs.js
Normal file
@ -0,0 +1,15 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { validateApiKey } = require('../middleware/auth');
|
||||
const { getLatestPrompts, getLatestErrors } = require('../api/network/logs');
|
||||
|
||||
// Apply API key validation to all routes
|
||||
router.use(validateApiKey);
|
||||
|
||||
// Get latest prompts
|
||||
router.get('/prompts', getLatestPrompts);
|
||||
|
||||
// Get latest error logs
|
||||
router.get('/logs', getLatestErrors);
|
||||
|
||||
module.exports = router;
|
86
src/server.js
Normal file
86
src/server.js
Normal file
@ -0,0 +1,86 @@
|
||||
require('dotenv').config();
|
||||
const express = require('express');
|
||||
const bodyParser = require('body-parser');
|
||||
const generateRoutes = require('./routes/generate');
|
||||
const logsRoutes = require('./routes/logs');
|
||||
const initDatabase = require('./database/init');
|
||||
|
||||
// Create Express app
|
||||
const app = express();
|
||||
const port = process.env.PORT || 5000;
|
||||
|
||||
// Middleware
|
||||
app.use(bodyParser.json());
|
||||
|
||||
// Health check endpoint
|
||||
app.get('/', (req, res) => {
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'AI API server is running',
|
||||
version: '1.0.0'
|
||||
});
|
||||
});
|
||||
|
||||
// API routes
|
||||
app.use('/api/generate', generateRoutes);
|
||||
app.use('/api/errors', logsRoutes);
|
||||
|
||||
// Error handling middleware
|
||||
app.use((err, req, res, next) => {
|
||||
console.error('Unhandled error:', err);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Internal Server Error',
|
||||
message: err.message
|
||||
});
|
||||
});
|
||||
|
||||
// Initialize database and start server
|
||||
async function startServer() {
|
||||
try {
|
||||
// Initialize database
|
||||
await initDatabase();
|
||||
|
||||
// Create server instance
|
||||
const server = app.listen(port, () => {
|
||||
console.log(`🚀 Server running on http://localhost:${port}`);
|
||||
console.log('📝 Environment:', process.env.NODE_ENV || 'development');
|
||||
}).on('error', (err) => {
|
||||
if (err.code === 'EADDRINUSE') {
|
||||
console.error(`❌ Port ${port} is already in use. Please try these steps:`);
|
||||
console.error('1. Find the process using the port:');
|
||||
console.error(` lsof -i :${port}`);
|
||||
console.error('2. Kill the process:');
|
||||
console.error(` kill -9 <PID>`);
|
||||
console.error('3. Or use a different port by setting PORT in .env');
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.error('❌ Server error:', err);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Graceful shutdown
|
||||
process.on('SIGTERM', () => {
|
||||
console.log('👋 SIGTERM received. Shutting down gracefully...');
|
||||
server.close(() => {
|
||||
console.log('✅ Server closed');
|
||||
process.exit(0);
|
||||
});
|
||||
});
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
console.log('👋 SIGINT received. Shutting down gracefully...');
|
||||
server.close(() => {
|
||||
console.log('✅ Server closed');
|
||||
process.exit(0);
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to start server:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Start the server
|
||||
startServer();
|
Reference in New Issue
Block a user