This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
import { timingSafeEqual } from 'node:crypto';
|
||||
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
|
||||
import { z } from 'zod';
|
||||
import type { Database } from '@pig/db';
|
||||
import {
|
||||
PrimeOpenAIChatProvider,
|
||||
type PiggyChatEvent,
|
||||
type PiggyChatRequest,
|
||||
} from './chat';
|
||||
import { createInteractivePigTools } from './chat-tools';
|
||||
|
||||
const requestSchema = z
|
||||
.object({
|
||||
principalUserId: z.string().uuid(),
|
||||
message: z.string().trim().min(1).max(4_000),
|
||||
history: z
|
||||
.array(
|
||||
z.object({
|
||||
role: z.enum(['user', 'assistant']),
|
||||
content: z.string().min(1).max(8_000),
|
||||
}),
|
||||
)
|
||||
.max(20)
|
||||
.optional(),
|
||||
context: z
|
||||
.object({
|
||||
type: z.enum([
|
||||
'account',
|
||||
'contact',
|
||||
'demand_deal',
|
||||
'supply_deal',
|
||||
'contract',
|
||||
'commitment',
|
||||
]),
|
||||
id: z.string().uuid(),
|
||||
label: z.string().max(240).optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
interface ChatRunner {
|
||||
readonly model: string;
|
||||
run(request: PiggyChatRequest): AsyncIterable<PiggyChatEvent>;
|
||||
}
|
||||
|
||||
export interface PiggyChatServerOptions {
|
||||
host?: string;
|
||||
port: number;
|
||||
internalToken: string;
|
||||
provider: ChatRunner;
|
||||
allowNonLoopback?: boolean;
|
||||
}
|
||||
|
||||
export function startPiggyChatServer(
|
||||
db: Database,
|
||||
options: PiggyChatServerOptions,
|
||||
): Server {
|
||||
const host = options.host ?? '127.0.0.1';
|
||||
if (!isLoopback(host) && !options.allowNonLoopback) {
|
||||
throw new Error('Piggy chat must bind to loopback; expose it only through the authenticated CRM API.');
|
||||
}
|
||||
if (options.internalToken.length < 32) {
|
||||
throw new Error('PIGGY_INTERNAL_TOKEN must contain at least 32 characters.');
|
||||
}
|
||||
|
||||
const server = createServer(async (request, response) => {
|
||||
if (request.method !== 'POST' || request.url !== '/internal/chat') {
|
||||
response.writeHead(404).end();
|
||||
return;
|
||||
}
|
||||
if (!tokenMatches(request.headers.authorization, options.internalToken)) {
|
||||
response.writeHead(401, { 'content-type': 'application/json' });
|
||||
response.end(JSON.stringify({ error: 'Unauthorised internal request.' }));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const body = requestSchema.parse(JSON.parse(await readBoundedBody(request, 32_768)));
|
||||
const abort = new AbortController();
|
||||
response.on('close', () => abort.abort());
|
||||
response.writeHead(200, {
|
||||
'content-type': 'application/x-ndjson; charset=utf-8',
|
||||
'cache-control': 'no-cache, no-transform',
|
||||
'x-content-type-options': 'nosniff',
|
||||
});
|
||||
|
||||
for await (const event of options.provider.run({
|
||||
message: body.message,
|
||||
history: body.history,
|
||||
context: body.context,
|
||||
tools: createInteractivePigTools(db, body.context),
|
||||
signal: abort.signal,
|
||||
})) {
|
||||
response.write(`${JSON.stringify(event)}\n`);
|
||||
}
|
||||
response.end();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Piggy chat failed.';
|
||||
if (!response.headersSent) {
|
||||
response.writeHead(error instanceof z.ZodError ? 400 : 500, {
|
||||
'content-type': 'application/json',
|
||||
});
|
||||
response.end(JSON.stringify({ error: message }));
|
||||
return;
|
||||
}
|
||||
response.end(`${JSON.stringify({ type: 'error', message })}\n`);
|
||||
}
|
||||
});
|
||||
server.listen(options.port, host);
|
||||
return server;
|
||||
}
|
||||
|
||||
export function createPrimeChatProvider(options: ConstructorParameters<typeof PrimeOpenAIChatProvider>[0]) {
|
||||
return new PrimeOpenAIChatProvider(options);
|
||||
}
|
||||
|
||||
function tokenMatches(header: string | undefined, expected: string): boolean {
|
||||
const supplied = header?.startsWith('Bearer ') ? header.slice(7) : '';
|
||||
const suppliedBytes = Buffer.from(supplied);
|
||||
const expectedBytes = Buffer.from(expected);
|
||||
return (
|
||||
suppliedBytes.length === expectedBytes.length &&
|
||||
timingSafeEqual(suppliedBytes, expectedBytes)
|
||||
);
|
||||
}
|
||||
|
||||
async function readBoundedBody(request: IncomingMessage, maximumBytes: number): Promise<string> {
|
||||
const chunks: Buffer[] = [];
|
||||
let size = 0;
|
||||
for await (const chunk of request) {
|
||||
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||
size += bytes.length;
|
||||
if (size > maximumBytes) throw new Error('Piggy chat request is too large.');
|
||||
chunks.push(bytes);
|
||||
}
|
||||
return Buffer.concat(chunks).toString('utf8');
|
||||
}
|
||||
|
||||
function isLoopback(host: string): boolean {
|
||||
return host === '127.0.0.1' || host === '::1' || host === 'localhost';
|
||||
}
|
||||
Reference in New Issue
Block a user