mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 07:40:59 +00:00
154 lines
6.6 KiB
TypeScript
154 lines
6.6 KiB
TypeScript
/**
|
|
* An "Own server" that is OpenAI-compatible but not Ollama, end to end: the
|
|
* whole app against a fake server on loopback that only answers under
|
|
* `/openai/v1`, the way Groq does, and 404s every Ollama path.
|
|
*
|
|
* Covered: connecting it as the instance connection (#2808), the refusal that
|
|
* says the server answered without a model list, a refused key, and a booking
|
|
* read through it by AI parsing, which used to call Ollama's `/api/chat` and
|
|
* fail with the server's 404 (#2806).
|
|
*/
|
|
import { buildApp } from '../../src/bootstrap';
|
|
import { withRequestContext } from '../../src/nest/database/request-context';
|
|
import { LlmParseService } from '../../src/nest/llm-parse/llm-parse.service';
|
|
import { setAddonEnabled } from '../helpers/factories/settings';
|
|
import { makeUser } from '../helpers/factories/users';
|
|
import { resetRateLimits } from '../helpers/test-db';
|
|
import { sessionCookie } from './harness';
|
|
import { MikroORM } from '@mikro-orm/core';
|
|
import type { INestApplication } from '@nestjs/common';
|
|
|
|
import { createServer, type IncomingMessage, type Server as HttpServer, type ServerResponse } from 'node:http';
|
|
import type { AddressInfo } from 'node:net';
|
|
import request from 'supertest';
|
|
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
|
|
|
// ── The fake server ──────────────────────────────────────────────────────────
|
|
|
|
/** How the fake answers: as Groq does, with 200 and a page for every path, or refusing every key. */
|
|
const server = { mode: 'groq' as 'groq' | 'html' | 'refuse', seen: [] as string[], chats: [] as unknown[] };
|
|
|
|
async function readBody(req: IncomingMessage): Promise<string> {
|
|
let text = '';
|
|
for await (const chunk of req) text += String(chunk);
|
|
return text;
|
|
}
|
|
|
|
function json(res: ServerResponse, status: number, body: unknown): void {
|
|
res.writeHead(status, { 'content-type': 'application/json' });
|
|
res.end(JSON.stringify(body));
|
|
}
|
|
|
|
async function answer(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
server.seen.push(`${req.method} ${req.url}`);
|
|
const body = await readBody(req);
|
|
if (server.mode === 'refuse') return json(res, 401, { error: { message: 'Invalid API Key' } });
|
|
if (server.mode === 'html') {
|
|
res.writeHead(200, { 'content-type': 'text/html' });
|
|
res.end('<html><body>Sign in</body></html>');
|
|
return;
|
|
}
|
|
if (req.method === 'GET' && req.url === '/openai/v1/models') {
|
|
return json(res, 200, { object: 'list', data: [{ id: 'openai/gpt-oss-120b', object: 'model' }] });
|
|
}
|
|
if (req.method === 'POST' && req.url === '/openai/v1/chat/completions') {
|
|
server.chats.push(JSON.parse(body));
|
|
const hotel = {
|
|
name: 'Hotel Sakura',
|
|
address: '1-2-3 Asakusa, Tokyo',
|
|
checkin_time: '2026-11-02T15:00:00',
|
|
checkout_time: '2026-11-05T11:00:00',
|
|
};
|
|
const result = { result: [hotel] };
|
|
return json(res, 200, { choices: [{ message: { content: JSON.stringify(result) }, finish_reason: 'stop' }] });
|
|
}
|
|
json(res, 404, {
|
|
error: {
|
|
message: `Unknown request URL: ${req.method} ${req.url}.`,
|
|
type: 'invalid_request_error',
|
|
code: 'unknown_url',
|
|
},
|
|
});
|
|
}
|
|
|
|
// ── The app ──────────────────────────────────────────────────────────────────
|
|
|
|
let nestApp: INestApplication;
|
|
let orm: MikroORM;
|
|
let fake: HttpServer;
|
|
let adminId: number;
|
|
|
|
function connect(apiKey = 'gsk_test'): request.Test {
|
|
const { port } = fake.address() as AddressInfo;
|
|
return request(nestApp.getHttpServer())
|
|
.put('/api/admin/ai/connection')
|
|
.set('Cookie', sessionCookie(adminId))
|
|
.send({ kind: 'custom', baseUrl: `http://127.0.0.1:${port}/openai/v1`, apiKey, model: 'openai/gpt-oss-120b' });
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
fake = createServer((req, res) => {
|
|
answer(req, res).catch(() => res.destroy());
|
|
});
|
|
await new Promise<void>((resolve) => fake.listen(0, '127.0.0.1', resolve));
|
|
nestApp = await buildApp();
|
|
orm = nestApp.get(MikroORM);
|
|
await setAddonEnabled(orm, 'ai_assistant', true);
|
|
adminId = (await makeUser(orm, { role: 'admin' })).user.id;
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
await resetRateLimits(nestApp);
|
|
server.mode = 'groq';
|
|
server.seen = [];
|
|
server.chats = [];
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await nestApp.close();
|
|
await new Promise<void>((resolve) => fake.close(() => resolve()));
|
|
});
|
|
|
|
// ── Cases ────────────────────────────────────────────────────────────────────
|
|
|
|
describe('An OpenAI-compatible own server', () => {
|
|
it('AIOWN-E2E-001: connects, its models read from the list at the address as entered (#2808)', async () => {
|
|
const res = await connect();
|
|
expect(res.status).toBe(200);
|
|
expect(server.seen.slice(0, 2)).toEqual(['GET /openai/api/tags', 'GET /openai/v1/models']);
|
|
});
|
|
|
|
it('AIOWN-E2E-002: a server that answers without a model list is told apart from one that is down', async () => {
|
|
server.mode = 'html';
|
|
// Its own key, so the list the first case read is not taken from the catalog's cache.
|
|
const res = await connect('gsk_html');
|
|
expect(res.status).toBe(502);
|
|
expect(res.body).toMatchObject({ code: 'provider_unavailable', reason: 'no_model_list' });
|
|
});
|
|
|
|
it('AIOWN-E2E-003: a refused key reads as a refused key, not as a server that is down', async () => {
|
|
server.mode = 'refuse';
|
|
const res = await connect('wrong');
|
|
expect(res.status).toBe(422);
|
|
expect(res.body).toMatchObject({ code: 'invalid_key' });
|
|
});
|
|
|
|
it('AIOWN-E2E-004: AI parsing reads a booking through /chat/completions when /api/chat is missing (#2806)', async () => {
|
|
expect((await connect()).status).toBe(200);
|
|
await setAddonEnabled(orm, 'llm_parsing', true);
|
|
server.seen = [];
|
|
// Outside a request, so it needs its own ORM context, as a job would.
|
|
const booking = {
|
|
buffer: Buffer.from('Booking confirmation\nHotel Sakura, Tokyo\nCheck-in 2 Nov 2026, check-out 5 Nov 2026'),
|
|
originalName: 'booking.txt',
|
|
};
|
|
const out = await withRequestContext(orm, () => nestApp.get(LlmParseService).parse(booking, adminId));
|
|
expect(out.warnings.filter((w) => /failed/i.test(w))).toEqual([]);
|
|
expect(out.kiItems).toHaveLength(1);
|
|
expect(server.seen).toContain('POST /openai/api/chat');
|
|
expect(server.seen).toContain('POST /openai/v1/chat/completions');
|
|
const sent = server.chats[0] as { model: string; messages: { role: string }[] };
|
|
expect(sent.model).toBe('openai/gpt-oss-120b');
|
|
});
|
|
});
|