diff --git a/scripts/llm-stub/server.mjs b/scripts/llm-stub/server.mjs new file mode 100644 index 0000000000..23eea046ab --- /dev/null +++ b/scripts/llm-stub/server.mjs @@ -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}`); + }); +} diff --git a/scripts/llm-stub/server.test.mjs b/scripts/llm-stub/server.test.mjs new file mode 100644 index 0000000000..aae7ef991a --- /dev/null +++ b/scripts/llm-stub/server.test.mjs @@ -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 () => { + 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(); + } +});