* feat: add POST /api/v1/chat endpoint for sending messages to agents Adds an HTTP endpoint that accepts a JSON message, sends it to the lettabot agent via sendToAgent(), and returns the agent's response. This enables external systems (e.g. server-side tools in other agents) to communicate with lettabot programmatically. - Add ChatRequest/ChatResponse types - Add AgentRouter interface extending MessageDeliverer with sendToAgent() - Implement AgentRouter on LettaGateway with agent-name routing - Add POST /api/v1/chat route with auth, validation, and JSON body parsing Written by Cameron ◯ Letta Code "The most profound technologies are those that disappear." -- Mark Weiser * feat: add SSE streaming support to /api/v1/chat endpoint When the client sends Accept: text/event-stream, the chat endpoint streams SDK messages as SSE events instead of waiting for the full response. Each event is a JSON StreamMsg (assistant, tool_call, tool_result, reasoning, result). The result event signals end-of-stream. - Export StreamMsg type from bot.ts - Add streamToAgent() to AgentSession interface and LettaBot - Wire streamToAgent() through LettaGateway with agent-name routing - Add SSE path in chat route (Accept header content negotiation) - Handle client disconnect mid-stream gracefully Written by Cameron ◯ Letta Code "Any sufficiently advanced technology is indistinguishable from magic." -- Arthur C. Clarke * test+docs: add chat endpoint tests and API documentation - 10 tests for POST /api/v1/chat: auth, validation, sync response, agent routing, SSE streaming, stream error handling - 6 tests for gateway sendToAgent/streamToAgent routing - Fix timingSafeEqual crash on mismatched key lengths (return 401, not 500) - Document chat endpoint in configuration.md with sync and SSE examples - Add Chat API link to docs/README.md index Written by Cameron ◯ Letta Code "First, solve the problem. Then, write the code." -- John Johnson
303 lines
9.6 KiB
TypeScript
303 lines
9.6 KiB
TypeScript
/**
|
|
* HTTP API server for LettaBot
|
|
* Provides endpoints for CLI to send messages across Docker boundaries
|
|
*/
|
|
|
|
import * as http from 'http';
|
|
import * as fs from 'fs';
|
|
import { validateApiKey } from './auth.js';
|
|
import type { SendMessageRequest, SendMessageResponse, SendFileResponse, ChatRequest, ChatResponse } from './types.js';
|
|
import { parseMultipart } from './multipart.js';
|
|
import type { AgentRouter } from '../core/interfaces.js';
|
|
import type { ChannelId } from '../core/types.js';
|
|
|
|
const VALID_CHANNELS: ChannelId[] = ['telegram', 'slack', 'discord', 'whatsapp', 'signal'];
|
|
const MAX_BODY_SIZE = 10 * 1024; // 10KB
|
|
const MAX_TEXT_LENGTH = 10000; // 10k chars
|
|
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB
|
|
|
|
interface ServerOptions {
|
|
port: number;
|
|
apiKey: string;
|
|
host?: string; // Bind address (default: 127.0.0.1 for security)
|
|
corsOrigin?: string; // CORS origin (default: same-origin only)
|
|
}
|
|
|
|
/**
|
|
* Create and start the HTTP API server
|
|
*/
|
|
export function createApiServer(deliverer: AgentRouter, options: ServerOptions): http.Server {
|
|
const server = http.createServer(async (req, res) => {
|
|
// Set CORS headers (configurable origin, defaults to same-origin for security)
|
|
const corsOrigin = options.corsOrigin || req.headers.origin || 'null';
|
|
res.setHeader('Access-Control-Allow-Origin', corsOrigin);
|
|
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-Api-Key');
|
|
|
|
// Handle OPTIONS preflight
|
|
if (req.method === 'OPTIONS') {
|
|
res.writeHead(204);
|
|
res.end();
|
|
return;
|
|
}
|
|
|
|
// Route: GET /health or GET /
|
|
if ((req.url === '/health' || req.url === '/') && req.method === 'GET') {
|
|
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
|
res.end('ok');
|
|
return;
|
|
}
|
|
|
|
// Route: POST /api/v1/messages (unified: supports both text and files)
|
|
if (req.url === '/api/v1/messages' && req.method === 'POST') {
|
|
try {
|
|
// Validate authentication
|
|
if (!validateApiKey(req.headers, options.apiKey)) {
|
|
sendError(res, 401, 'Unauthorized');
|
|
return;
|
|
}
|
|
|
|
const contentType = req.headers['content-type'] || '';
|
|
|
|
// Parse multipart/form-data (supports both text-only and file uploads)
|
|
if (!contentType.includes('multipart/form-data')) {
|
|
sendError(res, 400, 'Content-Type must be multipart/form-data');
|
|
return;
|
|
}
|
|
|
|
// Parse multipart data
|
|
const { fields, files } = await parseMultipart(req, MAX_FILE_SIZE);
|
|
|
|
// Validate required fields
|
|
if (!fields.channel || !fields.chatId) {
|
|
sendError(res, 400, 'Missing required fields: channel, chatId');
|
|
return;
|
|
}
|
|
|
|
if (!VALID_CHANNELS.includes(fields.channel as ChannelId)) {
|
|
sendError(res, 400, `Invalid channel: ${fields.channel}`, 'channel');
|
|
return;
|
|
}
|
|
|
|
// Validate that either text or file is provided
|
|
if (!fields.text && files.length === 0) {
|
|
sendError(res, 400, 'Either text or file must be provided');
|
|
return;
|
|
}
|
|
|
|
const file = files.length > 0 ? files[0] : undefined;
|
|
|
|
// Send via unified deliverer method
|
|
const messageId = await deliverer.deliverToChannel(
|
|
fields.channel as ChannelId,
|
|
fields.chatId,
|
|
{
|
|
text: fields.text,
|
|
filePath: file?.tempPath,
|
|
kind: fields.kind as 'image' | 'file' | undefined,
|
|
}
|
|
);
|
|
|
|
// Cleanup temp file if any
|
|
if (file) {
|
|
try {
|
|
fs.unlinkSync(file.tempPath);
|
|
} catch (err) {
|
|
console.warn('[API] Failed to cleanup temp file:', err);
|
|
}
|
|
}
|
|
|
|
// Success response
|
|
const response: SendMessageResponse = {
|
|
success: true,
|
|
messageId,
|
|
};
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify(response));
|
|
} catch (error: any) {
|
|
console.error('[API] Error handling request:', error);
|
|
sendError(res, 500, error.message || 'Internal server error');
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Route: POST /api/v1/chat (send a message to the agent, get response)
|
|
if (req.url === '/api/v1/chat' && req.method === 'POST') {
|
|
try {
|
|
if (!validateApiKey(req.headers, options.apiKey)) {
|
|
sendError(res, 401, 'Unauthorized');
|
|
return;
|
|
}
|
|
|
|
const contentType = req.headers['content-type'] || '';
|
|
if (!contentType.includes('application/json')) {
|
|
sendError(res, 400, 'Content-Type must be application/json');
|
|
return;
|
|
}
|
|
|
|
const body = await readBody(req, MAX_BODY_SIZE);
|
|
let chatReq: ChatRequest;
|
|
try {
|
|
chatReq = JSON.parse(body);
|
|
} catch {
|
|
sendError(res, 400, 'Invalid JSON body');
|
|
return;
|
|
}
|
|
|
|
if (!chatReq.message || typeof chatReq.message !== 'string') {
|
|
sendError(res, 400, 'Missing required field: message');
|
|
return;
|
|
}
|
|
|
|
if (chatReq.message.length > MAX_TEXT_LENGTH) {
|
|
sendError(res, 400, `Message too long (max ${MAX_TEXT_LENGTH} chars)`);
|
|
return;
|
|
}
|
|
|
|
// Resolve agent name (defaults to first agent)
|
|
const agentName = chatReq.agent;
|
|
const agentNames = deliverer.getAgentNames();
|
|
const resolvedName = agentName || agentNames[0];
|
|
|
|
if (agentName && !agentNames.includes(agentName)) {
|
|
sendError(res, 404, `Agent not found: ${agentName}. Available: ${agentNames.join(', ')}`);
|
|
return;
|
|
}
|
|
|
|
console.log(`[API] Chat request for agent "${resolvedName}": ${chatReq.message.slice(0, 100)}...`);
|
|
|
|
const context = { type: 'webhook' as const, outputMode: 'silent' as const };
|
|
const wantsStream = (req.headers.accept || '').includes('text/event-stream');
|
|
|
|
if (wantsStream) {
|
|
// SSE streaming: forward SDK stream chunks as events
|
|
res.writeHead(200, {
|
|
'Content-Type': 'text/event-stream',
|
|
'Cache-Control': 'no-cache',
|
|
'Connection': 'keep-alive',
|
|
});
|
|
|
|
let clientDisconnected = false;
|
|
req.on('close', () => { clientDisconnected = true; });
|
|
|
|
try {
|
|
for await (const msg of deliverer.streamToAgent(agentName, chatReq.message, context)) {
|
|
if (clientDisconnected) break;
|
|
res.write(`data: ${JSON.stringify(msg)}\n\n`);
|
|
if (msg.type === 'result') break;
|
|
}
|
|
} catch (streamError: any) {
|
|
if (!clientDisconnected) {
|
|
res.write(`data: ${JSON.stringify({ type: 'error', error: streamError.message })}\n\n`);
|
|
}
|
|
}
|
|
res.end();
|
|
} else {
|
|
// Sync: wait for full response
|
|
const response = await deliverer.sendToAgent(agentName, chatReq.message, context);
|
|
|
|
const chatRes: ChatResponse = {
|
|
success: true,
|
|
response,
|
|
agentName: resolvedName,
|
|
};
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify(chatRes));
|
|
}
|
|
} catch (error: any) {
|
|
console.error('[API] Chat error:', error);
|
|
const chatRes: ChatResponse = {
|
|
success: false,
|
|
error: error.message || 'Internal server error',
|
|
};
|
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify(chatRes));
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Route: 404 Not Found
|
|
sendError(res, 404, 'Not found');
|
|
});
|
|
|
|
// Bind to localhost by default for security (prevents network exposure on bare metal)
|
|
// Use API_HOST=0.0.0.0 in Docker to expose on all interfaces
|
|
const host = options.host || '127.0.0.1';
|
|
server.listen(options.port, host, () => {
|
|
console.log(`[API] Server listening on ${host}:${options.port}`);
|
|
});
|
|
|
|
return server;
|
|
}
|
|
|
|
/**
|
|
* Read request body with size limit
|
|
*/
|
|
function readBody(req: http.IncomingMessage, maxSize: number): Promise<string> {
|
|
return new Promise((resolve, reject) => {
|
|
let body = '';
|
|
let size = 0;
|
|
|
|
req.on('data', (chunk: Buffer) => {
|
|
size += chunk.length;
|
|
if (size > maxSize) {
|
|
reject(new Error(`Request body too large (max ${maxSize} bytes)`));
|
|
return;
|
|
}
|
|
body += chunk.toString();
|
|
});
|
|
|
|
req.on('end', () => {
|
|
resolve(body);
|
|
});
|
|
|
|
req.on('error', (error) => {
|
|
reject(error);
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Validate send message request
|
|
*/
|
|
function validateRequest(request: SendMessageRequest): { message: string; field?: string } | null {
|
|
if (!request.channel) {
|
|
return { message: 'Missing required field: channel', field: 'channel' };
|
|
}
|
|
|
|
if (!request.chatId) {
|
|
return { message: 'Missing required field: chatId', field: 'chatId' };
|
|
}
|
|
|
|
if (!request.text) {
|
|
return { message: 'Missing required field: text', field: 'text' };
|
|
}
|
|
|
|
if (!VALID_CHANNELS.includes(request.channel as ChannelId)) {
|
|
return { message: `Invalid channel: ${request.channel}`, field: 'channel' };
|
|
}
|
|
|
|
if (typeof request.text !== 'string') {
|
|
return { message: 'Field "text" must be a string', field: 'text' };
|
|
}
|
|
|
|
if (request.text.length > MAX_TEXT_LENGTH) {
|
|
return { message: `Text too long (max ${MAX_TEXT_LENGTH} chars)`, field: 'text' };
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Send error response
|
|
*/
|
|
function sendError(res: http.ServerResponse, status: number, message: string, field?: string): void {
|
|
const response: SendMessageResponse = {
|
|
success: false,
|
|
error: message,
|
|
field,
|
|
};
|
|
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify(response));
|
|
}
|