Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions scripts/llm-stub/server.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
#!/usr/bin/env node
// Deterministic OpenAI-compatible chat stub for ./dev.sh up --ephemeral (D177, TASK-620).
// Native agents call LITELLM_BASE_URL/chat/completions; pointing that at this server lets a
// local walk see "agent replies" with no provider key and no quota. The reply text is fixed,
// so a walk can assert on it.
//
// LLM_STUB_PORT=5102 node scripts/llm-stub/server.mjs

import http from 'node:http';
import { pathToFileURL } from 'node:url';

export const STUB_REPLY = 'Scout stub reply: the local stack answered.';

function completion(model) {
const now = Math.floor(Date.now() / 1000);
return {
id: 'chatcmpl-stub',
object: 'chat.completion',
created: now,
model: model || 'stub',
choices: [{
index: 0,
message: { role: 'assistant', content: STUB_REPLY },
finish_reason: 'stop',
}],
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
};
}

export function createStubServer() {
return http.createServer((req, res) => {
const send = (status, body) => {
res.writeHead(status, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(body));
};
if (req.method === 'GET' && req.url === '/health') {
send(200, { ok: true });
return;
}
if (req.method === 'GET' && (req.url === '/v1/models' || req.url === '/models')) {
send(200, { object: 'list', data: [{ id: 'stub', object: 'model' }] });
return;
}
if (req.method === 'POST' && (req.url === '/v1/chat/completions' || req.url === '/chat/completions')) {
let raw = '';
req.on('data', (chunk) => { raw += chunk; });
req.on('end', () => {
let model;
try { model = JSON.parse(raw || '{}').model; } catch { /* a malformed body still gets a reply */ }
send(200, completion(model));
});
return;
}
send(404, { error: { message: `no stub route for ${req.method} ${req.url}` } });
});
}

if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
const port = Number(process.env.LLM_STUB_PORT || 5102);
createStubServer().listen(port, '127.0.0.1', () => {
console.log(`llm stub on 127.0.0.1:${port}`);
});
}
90 changes: 90 additions & 0 deletions scripts/llm-stub/server.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
// Run: node --test scripts/llm-stub/server.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createStubServer, STUB_REPLY } from './server.mjs';

function listen(server) {
return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve(server.address().port)));
}

test('chat completions return the fixed reply in OpenAI shape, every time', async () => {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
const server = createStubServer();
const port = await listen(server);
try {
const body = JSON.stringify({ model: 'scout', messages: [{ role: 'user', content: 'hi' }] });
const call = () => fetch(`http://127.0.0.1:${port}/v1/chat/completions`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body,
}).then((r) => r.json());
const first = await call();
const second = await call();
assert.equal(first.object, 'chat.completion');
assert.equal(first.choices[0].message.role, 'assistant');
assert.equal(first.choices[0].message.content, STUB_REPLY);
assert.equal(first.choices[0].finish_reason, 'stop');
assert.equal(second.choices[0].message.content, first.choices[0].message.content);
assert.equal(first.model, 'scout');
} finally {
server.close();
}
});

test('health, unversioned routes and the 404 body', async () => {
const server = createStubServer();
const port = await listen(server);
const base = `http://127.0.0.1:${port}`;
try {
const health = await fetch(`${base}/health`);
assert.equal(health.status, 200);
assert.deepEqual(await health.json(), { ok: true });

const models = await fetch(`${base}/models`);
assert.equal(models.status, 200);
assert.equal((await models.json()).data[0].id, 'stub');

const chat = await fetch(`${base}/chat/completions`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: 'scout', messages: [] }),
});
assert.equal(chat.status, 200);
assert.equal((await chat.json()).choices[0].message.content, STUB_REPLY);

const missing = await fetch(`${base}/v1/nope`);
assert.equal(missing.status, 404);
const body = await missing.json();
assert.equal(body.error.message, 'no stub route for GET /v1/nope');
} finally {
server.close();
}
});

test('empty and malformed completion bodies still get the fixed reply with the default model', async () => {
const server = createStubServer();
const port = await listen(server);
const url = `http://127.0.0.1:${port}/v1/chat/completions`;
try {
for (const body of ['', '{not json']) {
const res = await fetch(url, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body,
});
assert.equal(res.status, 200, `status for body ${JSON.stringify(body)}`);
const json = await res.json();
assert.equal(json.choices[0].message.content, STUB_REPLY);
assert.equal(json.model, 'stub');
}
} finally {
server.close();
}
});

test('models list and unknown routes', async () => {
const server = createStubServer();
const port = await listen(server);
try {
const models = await fetch(`http://127.0.0.1:${port}/v1/models`).then((r) => r.json());
assert.equal(models.data[0].id, 'stub');
const missing = await fetch(`http://127.0.0.1:${port}/v1/nope`);
assert.equal(missing.status, 404);
} finally {
server.close();
}
});
Loading