Tutorial 04: Building a Chat API
Learn how to build a production-ready HTTP API server for chat completions using Express and aimatey.
What You’ll Build
Section titled “What You’ll Build”A complete chat API with:
- REST endpoint for chat completions
- Streaming support with Server-Sent Events
- Error handling and validation
- Rate limiting and security
- OpenAI-compatible API format
Time Required
Section titled “Time Required”⏱️ 25 minutes
Prerequisites
Section titled “Prerequisites”- Completed Tutorial 03: Multi-Provider Routing
- Basic understanding of HTTP and REST APIs
- Node.js 18+ installed
What We’re Building
Section titled “What We’re Building”An HTTP server that exposes an OpenAI-compatible /v1/chat/completions endpoint:
curl http://localhost:3000/v1/chat/completions \ -H "Content-Type: application/json" \ -d '{ "model": "gpt-4", "messages": [{"role": "user", "content": "Hello!"}] }'Step 1: Install Dependencies
Section titled “Step 1: Install Dependencies”mkdir ai-matey-apicd ai-matey-apinpm init -ynpm install express cors dotenvnpm install @johnhenry/aimatey-core @johnhenry/aimatey-frontend @johnhenry/aimatey-backend @johnhenry/aimatey-middlewarenpm install -D @types/express @types/corsStep 2: Create Basic Server
Section titled “Step 2: Create Basic Server”Create server.js:
import 'dotenv/config';import express from 'express';import cors from 'cors';import { Bridge } from '@johnhenry/aimatey-core';import { OpenAIFrontendAdapter } from '@johnhenry/aimatey-frontend/openai';import { AnthropicBackendAdapter } from '@johnhenry/aimatey-backend/anthropic';
const app = express();const PORT = process.env.PORT || 3000;
// Middlewareapp.use(cors());app.use(express.json());
// Create bridgeconst bridge = new Bridge( new OpenAIFrontendAdapter(), new AnthropicBackendAdapter({ apiKey: process.env.ANTHROPIC_API_KEY }));
// Health checkapp.get('/health', (req, res) => { res.json({ status: 'ok', timestamp: Date.now() });});
// Chat completions endpointapp.post('/v1/chat/completions', async (req, res) => { try { const response = await bridge.chat(req.body); res.json(response); } catch (error) { console.error('Error:', error); res.status(500).json({ error: { message: error.message, type: 'api_error' } }); }});
// Start serverapp.listen(PORT, () => { console.log(`🚀 Server running on http://localhost:${PORT}`); console.log(`📝 API: http://localhost:${PORT}/v1/chat/completions`);});Create .env:
ANTHROPIC_API_KEY=your_api_key_herePORT=3000Update package.json:
{ "name": "ai-matey-api", "version": "1.0.0", "type": "module", "scripts": { "start": "node server.js", "dev": "node --watch server.js" }, "dependencies": { "express": "^4.18.0", "cors": "^2.8.5", "dotenv": "^16.0.0", "@johnhenry/aimatey-core": "latest", "@johnhenry/aimatey-frontend": "latest", "@johnhenry/aimatey-backend": "latest" }}Step 3: Test It
Section titled “Step 3: Test It”Start the server:
npm startTest with curl:
curl -X POST http://localhost:3000/v1/chat/completions \ -H "Content-Type: application/json" \ -d '{ "model": "gpt-4", "messages": [ {"role": "user", "content": "What is aimatey?"} ] }'Step 4: Add Streaming Support
Section titled “Step 4: Add Streaming Support”Streaming allows clients to receive responses in real-time:
import 'dotenv/config';import express from 'express';import cors from 'cors';import { Bridge } from '@johnhenry/aimatey-core';import { OpenAIFrontendAdapter } from '@johnhenry/aimatey-frontend/openai';import { AnthropicBackendAdapter } from '@johnhenry/aimatey-backend/anthropic';
const app = express();app.use(cors());app.use(express.json());
const bridge = new Bridge( new OpenAIFrontendAdapter(), new AnthropicBackendAdapter({ apiKey: process.env.ANTHROPIC_API_KEY }));
app.post('/v1/chat/completions', async (req, res) => { try { // Check if streaming is requested if (req.body.stream) { // Set headers for Server-Sent Events res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive');
const stream = bridge.chatStream(req.body);
for await (const chunk of stream) { // Send chunk as SSE res.write(`data: ${JSON.stringify(chunk)}\n\n`); }
// Send done signal res.write('data: [DONE]\n\n'); res.end(); } else { // Non-streaming response const response = await bridge.chat(req.body); res.json(response); } } catch (error) { console.error('Error:', error);
if (!res.headersSent) { res.status(500).json({ error: { message: error.message, type: 'api_error' } }); } else { // If streaming already started, send error event res.write(`data: ${JSON.stringify({ error: error.message })}\n\n`); res.end(); } }});
app.listen(3000, () => { console.log('🚀 Server running on http://localhost:3000');});Test streaming:
curl -X POST http://localhost:3000/v1/chat/completions \ -H "Content-Type: application/json" \ -d '{ "model": "gpt-4", "messages": [{"role": "user", "content": "Count to 5"}], "stream": true }'Step 5: Add Request Validation
Section titled “Step 5: Add Request Validation”Validate incoming requests:
// Validation middlewarefunction validateChatRequest(req, res, next) { const { model, messages } = req.body;
// Check required fields if (!model) { return res.status(400).json({ error: { message: 'Missing required field: model', type: 'invalid_request_error' } }); }
if (!messages || !Array.isArray(messages)) { return res.status(400).json({ error: { message: 'Missing or invalid field: messages', type: 'invalid_request_error' } }); }
if (messages.length === 0) { return res.status(400).json({ error: { message: 'messages array cannot be empty', type: 'invalid_request_error' } }); }
// Validate message format for (const msg of messages) { if (!msg.role || !msg.content) { return res.status(400).json({ error: { message: 'Each message must have role and content', type: 'invalid_request_error' } }); }
if (!['system', 'user', 'assistant'].includes(msg.role)) { return res.status(400).json({ error: { message: `Invalid role: ${msg.role}`, type: 'invalid_request_error' } }); } }
next();}
// Use validationapp.post('/v1/chat/completions', validateChatRequest, async (req, res) => { // ... rest of handler});Step 6: Add Rate Limiting
Section titled “Step 6: Add Rate Limiting”Protect your API from abuse:
npm install express-rate-limitimport rateLimit from 'express-rate-limit';
// Rate limiterconst limiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 100, // Max 100 requests per window message: { error: { message: 'Too many requests, please try again later', type: 'rate_limit_exceeded' } }, standardHeaders: true, legacyHeaders: false,});
// Apply to all routesapp.use('/v1/', limiter);Step 7: Add Authentication (Optional)
Section titled “Step 7: Add Authentication (Optional)”Require API keys:
// API key middlewarefunction requireApiKey(req, res, next) { const apiKey = req.headers['authorization']?.replace('Bearer ', '');
if (!apiKey) { return res.status(401).json({ error: { message: 'Missing API key', type: 'authentication_error' } }); }
// Validate API key (in production, check against database) const validKeys = process.env.VALID_API_KEYS?.split(',') || [];
if (!validKeys.includes(apiKey)) { return res.status(401).json({ error: { message: 'Invalid API key', type: 'authentication_error' } }); }
next();}
// Require auth for chat endpointapp.post('/v1/chat/completions', requireApiKey, validateChatRequest, async (req, res) => { // ... rest of handler});Update .env:
VALID_API_KEYS=sk-test-123,sk-test-456Test with API key:
curl -X POST http://localhost:3000/v1/chat/completions \ -H "Content-Type: application/json" \ -H "Authorization: Bearer sk-test-123" \ -d '{ "model": "gpt-4", "messages": [{"role": "user", "content": "Hello"}] }'Step 8: Add Middleware
Section titled “Step 8: Add Middleware”Use aimatey middleware for production features:
import { createLoggingMiddleware, createCachingMiddleware, createCostTrackingMiddleware} from '@johnhenry/aimatey-middleware';
// Add middleware to bridgebridge.use(createLoggingMiddleware({ level: 'info', sanitize: true // redacts API keys and tokens}));
bridge.use(createCachingMiddleware({ ttl: 3_600_000, // ttl is milliseconds maxSize: 1000}));
bridge.use(createCostTrackingMiddleware({ dailyThreshold: 100, onThresholdExceeded: (cost, threshold) => { console.error(`⚠️ Spend passed $${threshold} (last request $${cost.totalCost.toFixed(4)})`); }}));An API server answers for many people out of one process, so cache entries are scoped to a caller. Pass the caller’s identity with each request:
const response = await bridge.chat(req.body, { principal: req.user.id });Without it the response is simply not cached - the middleware will not risk
handing it to the next person who asks the same question. If your server really
does serve a single audience, say so once with
createCachingMiddleware({ unidentified: 'share' }).
Step 9: Add Multi-Provider Support
Section titled “Step 9: Add Multi-Provider Support”Use Router for high availability:
A Router is a backend adapter, not a bridge: it takes only a config object,
backends are added with register(), and requests still go through a Bridge.
import { Bridge, Router } from '@johnhenry/aimatey-core';import { OpenAIFrontendAdapter } from '@johnhenry/aimatey-frontend/openai';import { AnthropicBackendAdapter } from '@johnhenry/aimatey-backend/anthropic';import { OpenAIBackendAdapter } from '@johnhenry/aimatey-backend/openai';
const router = new Router({ routingStrategy: 'explicit', defaultBackend: 'anthropic', fallbackStrategy: 'sequential'});
router.register('anthropic', new AnthropicBackendAdapter({ apiKey: process.env.ANTHROPIC_API_KEY}));router.register('openai', new OpenAIBackendAdapter({ apiKey: process.env.OPENAI_API_KEY}));
router.setFallbackChain(['anthropic', 'openai']);
// The bridge stays the entry point - it just talks to the router nowconst bridge = new Bridge(new OpenAIFrontendAdapter(), router);
app.post('/v1/chat/completions', async (req, res) => { const response = req.body.stream ? bridge.chatStream(req.body) : await bridge.chat(req.body); // ... handle response});Complete Production Server
Section titled “Complete Production Server”Here’s the full production-ready server:
import 'dotenv/config';import express from 'express';import cors from 'cors';import rateLimit from 'express-rate-limit';import { Bridge, Router } from '@johnhenry/aimatey-core';import { OpenAIFrontendAdapter } from '@johnhenry/aimatey-frontend/openai';import { AnthropicBackendAdapter } from '@johnhenry/aimatey-backend/anthropic';import { OpenAIBackendAdapter } from '@johnhenry/aimatey-backend/openai';import { createLoggingMiddleware, createCachingMiddleware, createRetryMiddleware} from '@johnhenry/aimatey-middleware';
const app = express();const PORT = process.env.PORT || 3000;
// Express middlewareapp.use(cors());app.use(express.json({ limit: '1mb' }));
// Rate limitingconst limiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 100, message: { error: { message: 'Too many requests', type: 'rate_limit_exceeded' } }});app.use('/v1/', limiter);
// Create routerconst router = new Router({ routingStrategy: 'explicit', defaultBackend: 'anthropic', fallbackStrategy: 'sequential', healthCheckInterval: 60000});
router.register('anthropic', new AnthropicBackendAdapter({ apiKey: process.env.ANTHROPIC_API_KEY }));router.register('openai', new OpenAIBackendAdapter({ apiKey: process.env.OPENAI_API_KEY }));router.setFallbackChain(['anthropic', 'openai']);
// The router is the bridge's backendconst bridge = new Bridge(new OpenAIFrontendAdapter(), router);
// Add aimatey middleware - the middleware stack lives on the bridgebridge.use(createLoggingMiddleware({ level: 'info', sanitize: true }));bridge.use(createRetryMiddleware({ maxAttempts: 3 }));bridge.use(createCachingMiddleware({ ttl: 3_600_000 }));
// Validation middlewarefunction validateChatRequest(req, res, next) { const { model, messages } = req.body;
if (!model || !messages || !Array.isArray(messages) || messages.length === 0) { return res.status(400).json({ error: { message: 'Invalid request', type: 'invalid_request_error' } }); }
next();}
// Routesapp.get('/health', (req, res) => { res.json({ status: 'ok', timestamp: Date.now() });});
app.post('/v1/chat/completions', validateChatRequest, async (req, res) => { try { if (req.body.stream) { res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive');
const stream = bridge.chatStream(req.body);
for await (const chunk of stream) { res.write(`data: ${JSON.stringify(chunk)}\n\n`); }
res.write('data: [DONE]\n\n'); res.end(); } else { const response = await bridge.chat(req.body); res.json(response); } } catch (error) { console.error('Error:', error);
if (!res.headersSent) { res.status(500).json({ error: { message: error.message, type: 'api_error' } }); } }});
// Error handlerapp.use((err, req, res, next) => { console.error('Unhandled error:', err); res.status(500).json({ error: { message: 'Internal server error', type: 'server_error' } });});
// Start serverapp.listen(PORT, () => { console.log(`🚀 Server running on http://localhost:${PORT}`); console.log(`📝 Chat API: POST http://localhost:${PORT}/v1/chat/completions`); console.log(`❤️ Health: GET http://localhost:${PORT}/health`);});
// Graceful shutdownprocess.on('SIGTERM', () => { console.log('SIGTERM received, shutting down gracefully'); process.exit(0);});Testing Your API
Section titled “Testing Your API”# Non-streamingcurl -X POST http://localhost:3000/v1/chat/completions \ -H "Content-Type: application/json" \ -d '{"model":"gpt-4","messages":[{"role":"user","content":"Hello"}]}'
# Streamingcurl -X POST http://localhost:3000/v1/chat/completions \ -H "Content-Type: application/json" \ -d '{"model":"gpt-4","messages":[{"role":"user","content":"Count to 5"}],"stream":true}'JavaScript Client
Section titled “JavaScript Client”async function chat(message) { const response = await fetch('http://localhost:3000/v1/chat/completions', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ model: 'gpt-4', messages: [{ role: 'user', content: message }] }) });
const data = await response.json(); return data.choices[0].message.content;}
console.log(await chat('What is aimatey?'));Streaming Client
Section titled “Streaming Client”async function chatStream(message) { const response = await fetch('http://localhost:3000/v1/chat/completions', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ model: 'gpt-4', messages: [{ role: 'user', content: message }], stream: true }) });
const reader = response.body.getReader(); const decoder = new TextDecoder();
while (true) { const { done, value } = await reader.read(); if (done) break;
const chunk = decoder.decode(value); const lines = chunk.split('\n');
for (const line of lines) { if (line.startsWith('data: ')) { const data = line.slice(6); if (data === '[DONE]') return;
const json = JSON.parse(data); const content = json.choices?.[0]?.delta?.content; if (content) { process.stdout.write(content); } } } }}
await chatStream('Count to 10');Next Steps
Section titled “Next Steps”Congratulations! You’ve built a production-ready chat API.
Explore more:
Deployment
Section titled “Deployment”Docker
Section titled “Docker”FROM node:18-alpineWORKDIR /appCOPY package*.json ./RUN npm ci --only=productionCOPY . .EXPOSE 3000CMD ["node", "server.js"]docker build -t ai-matey-api .docker run -p 3000:3000 -e ANTHROPIC_API_KEY=your_key ai-matey-apiVercel
Section titled “Vercel”{ "version": 2, "builds": [{ "src": "server.js", "use": "@vercel/node" }], "routes": [{ "src": "/(.*)", "dest": "/server.js" }]}🎉 Tutorial Complete! You’ve mastered the aimatey basics. Explore Advanced Examples