139 lines
5.2 KiB
JavaScript
139 lines
5.2 KiB
JavaScript
// OpenAI-compatible stand-in for api.teamorouter.com — used by the node tests, no dependencies.
|
|
import { createServer } from 'node:http';
|
|
|
|
// 1x1 transparent PNG.
|
|
export const TINY_PNG_BASE64 =
|
|
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFAAH/q842iQAAAABJRU5ErkJggg==';
|
|
|
|
export const MODELS = [
|
|
{ id: 'gpt-5.6-sol', object: 'model', owned_by: 'openai', pricing: { prompt: 0.00000053, completion: 0.00000318 } },
|
|
{ id: 'claude-sonnet-5', object: 'model', owned_by: 'anthropic', pricing: { prompt: 0.0000005, completion: 0.00000252 } },
|
|
{ id: 'deepseek-flash-free', object: 'model', owned_by: 'deepseek' },
|
|
{ id: 'gemini-3.1-pro-preview', object: 'model', owned_by: 'google', pricing: { input: 0.29, output: 1.73 } },
|
|
{ id: 'gpt-image-2', object: 'model', owned_by: 'openai' },
|
|
{ id: 'nano-banana-2.1', object: 'model', owned_by: 'google' },
|
|
];
|
|
|
|
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
|
|
/**
|
|
* Models whose IDs start with "fail-" make the mock answer with the matching HTTP status,
|
|
* "slow-model" delays the answer, everything else echoes the user message back.
|
|
*/
|
|
export function createMockServer({ apiKey = 'test-key' } = {}) {
|
|
const requests = [];
|
|
|
|
const handle = async (request, response) => {
|
|
const chunks = [];
|
|
for await (const chunk of request) chunks.push(chunk);
|
|
const rawBody = Buffer.concat(chunks).toString('utf8');
|
|
let body;
|
|
try {
|
|
body = rawBody === '' ? undefined : JSON.parse(rawBody);
|
|
} catch {
|
|
body = undefined;
|
|
}
|
|
requests.push({ method: request.method, url: request.url, headers: request.headers, body });
|
|
|
|
const json = (status, payload) => {
|
|
response.writeHead(status, { 'content-type': 'application/json' });
|
|
response.end(JSON.stringify(payload));
|
|
};
|
|
|
|
if (!request.url.startsWith('/v1/')) {
|
|
return json(404, { error: { message: `unknown path ${request.url}` } });
|
|
}
|
|
if (request.headers.authorization !== `Bearer ${apiKey}`) {
|
|
return json(401, { error: { message: 'invalid api key' } });
|
|
}
|
|
|
|
if (request.method === 'GET' && request.url === '/v1/models') {
|
|
return json(200, { object: 'list', data: MODELS });
|
|
}
|
|
|
|
if (request.method === 'POST' && request.url === '/v1/chat/completions') {
|
|
const model = body?.model ?? '';
|
|
if (model.startsWith('fail-')) {
|
|
const status = Number(model.replace('fail-', ''));
|
|
return json(status, { error: { message: `forced failure ${status}` } });
|
|
}
|
|
if (model === 'slow-model') await delay(2500);
|
|
|
|
const userMessage = [...(body?.messages ?? [])].reverse().find((message) => message.role === 'user');
|
|
const content = `echo: ${userMessage?.content ?? ''}`;
|
|
|
|
if (body?.stream === true) {
|
|
response.writeHead(200, { 'content-type': 'text/event-stream' });
|
|
response.write(
|
|
`data: ${JSON.stringify({ object: 'chat.completion.chunk', choices: [{ index: 0, delta: { content } }] })}\n\n`,
|
|
);
|
|
response.write('data: [DONE]\n\n');
|
|
return response.end();
|
|
}
|
|
|
|
return json(200, {
|
|
object: 'chat.completion',
|
|
model,
|
|
choices: [{ index: 0, message: { role: 'assistant', content }, finish_reason: 'stop' }],
|
|
usage: { prompt_tokens: 11, completion_tokens: 4, total_tokens: 15 },
|
|
});
|
|
}
|
|
|
|
// LangChain's ChatOpenAI serves "gpt-5.6*" and "codex" model IDs through the Responses API.
|
|
if (request.method === 'POST' && request.url === '/v1/responses') {
|
|
const userMessage = (body?.input ?? []).find?.((message) => message.role === 'user');
|
|
const content = `echo: ${typeof body?.input === 'string' ? body.input : userMessage?.content ?? ''}`;
|
|
return json(200, {
|
|
id: 'resp_mock',
|
|
object: 'response',
|
|
created_at: 1,
|
|
status: 'completed',
|
|
model: body?.model,
|
|
output: [
|
|
{
|
|
type: 'message',
|
|
id: 'msg_mock',
|
|
role: 'assistant',
|
|
status: 'completed',
|
|
content: [{ type: 'output_text', text: content, annotations: [] }],
|
|
},
|
|
],
|
|
usage: { input_tokens: 11, output_tokens: 4, total_tokens: 15 },
|
|
});
|
|
}
|
|
|
|
if (request.method === 'POST' && request.url === '/v1/images/generations') {
|
|
if (String(body?.prompt ?? '').includes('noimage')) {
|
|
return json(400, { error: { message: 'prompt rejected' } });
|
|
}
|
|
return json(200, { created: 1, data: [{ b64_json: TINY_PNG_BASE64 }] });
|
|
}
|
|
|
|
return json(404, { error: { message: `unknown route ${request.method} ${request.url}` } });
|
|
};
|
|
|
|
const server = createServer((request, response) => {
|
|
handle(request, response).catch((error) => {
|
|
response.writeHead(500, { 'content-type': 'application/json' });
|
|
response.end(JSON.stringify({ error: { message: String(error) } }));
|
|
});
|
|
});
|
|
|
|
return {
|
|
requests,
|
|
/** Starts on `port` (0 = random) and returns the base URL, e.g. http://127.0.0.1:4123 */
|
|
async listen(port = 0) {
|
|
await new Promise((resolve) => server.listen(port, '127.0.0.1', resolve));
|
|
return `http://127.0.0.1:${server.address().port}`;
|
|
},
|
|
async close() {
|
|
await new Promise((resolve) => server.close(resolve));
|
|
},
|
|
};
|
|
}
|
|
|
|
if (process.argv[1]?.endsWith('mock-server.mjs') === true) {
|
|
const mock = createMockServer({ apiKey: process.env.MOCK_API_KEY ?? 'test-key' });
|
|
const baseUrl = await mock.listen(Number(process.env.PORT ?? 4123));
|
|
console.log(`mock TeamoRouter listening on ${baseUrl}/v1 (api key: ${process.env.MOCK_API_KEY ?? 'test-key'})`);
|
|
}
|