/** * Local stand-in for the model wire. * * The app server speaks the OpenAI Responses API (`wire_api = "responses"`): * one POST to `{base_url}/responses` answered with a `text/event-stream` of * typed events. Producing that stream here keeps every byte of the * conversation on this machine — no model provider is ever contacted. * * Extracted from `web-server.mjs` so the local model lives in one place: the * host serves it, and any other local caller gets the identical wire shape. */ /** Read a JSON request body with a hard size cap. */ export const readJsonBody = (req, limit = 4_000_000) => new Promise((resolve, reject) => { let raw = ""; req.setEncoding("utf8"); req.on("data", (c) => { raw += c; if (raw.length > limit) reject(new Error("body too large")); }); req.on("end", () => { try { resolve(raw ? JSON.parse(raw) : {}); } catch (err) { reject(err); } }); req.on("error", reject); }); /** Collect every text fragment the app server put in the request. */ const collectText = (payload) => { const texts = []; const collect = (item) => { if (!item || typeof item !== "object") return; if (typeof item.text === "string") texts.push(item.text); else if (Array.isArray(item.content)) for (const c of item.content) collect(c); else if (typeof item.content === "string") texts.push(item.content); }; for (const item of payload?.input ?? []) collect(item); if (typeof payload?.input === "string") texts.push(payload.input); return texts.join(" "); }; /** * Craft a deterministic local reply from the user's input. * * Deterministic arithmetic answers make end-to-end checks (`5+5 -> 10`) * readable at a glance; anything else echoes back what arrived so a wrong * request shape is immediately visible in the UI. */ export const mockReplyFor = (payload) => { const joined = collectText(payload); if (/5\s*\+\s*5/i.test(joined)) return "10"; if (/3\s*\+\s*3/i.test(joined)) return "6"; if (/2\s*\+\s*2/i.test(joined)) return "4"; if (/hello/i.test(joined)) return "hello from the local mock model"; return `Local mock reply. I received: ${joined.slice(0, 200) || "(empty input)"}`; }; let callCount = 0; const sseEvent = (res, type, data) => { res.write(`event: ${type}\ndata: ${JSON.stringify(data)}\n\n`); }; /** * Answer `/mockrelay/v1/*` — the model provider surface. * * @param {import("node:http").IncomingMessage} req * @param {import("node:http").ServerResponse} res * @param {string} urlPath path beginning with `/mockrelay` * @param {{ onRequest?: (line: string) => void }} [opts] */ export const serveMockRelay = (req, res, urlPath, opts = {}) => { opts.onRequest?.(`${req.method} ${urlPath} (mock model relay)`); const route = urlPath.replace(/^\/mockrelay/, "") || "/"; const json = (obj, status = 200) => { res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" }); res.end(JSON.stringify(obj)); }; // Model list — keeps provider probes happy. if (route === "/v1/models" || route === "/models") { json({ object: "list", data: [{ id: "gpt-6.1-sol", object: "model", owned_by: "local" }] }); return; } if (route === "/v1/responses" || route === "/responses") { if (req.method !== "POST") { res.writeHead(405).end("method not allowed"); return; } readJsonBody(req) .then((payload) => { const reply = mockReplyFor(payload); const model = typeof payload?.model === "string" ? payload.model : "gpt-6.1-sol"; const n = ++callCount; const respId = `resp_local_${Date.now().toString(36)}_${n}`; const msgId = `msg_local_${n}`; const now = Math.floor(Date.now() / 1000); const responseShell = (status, extra = {}) => ({ id: respId, object: "response", created_at: now, status, model, output: [], usage: null, ...extra, }); res.writeHead(200, { "content-type": "text/event-stream; charset=utf-8", "cache-control": "no-store", connection: "keep-alive", }); sseEvent(res, "response.created", { type: "response.created", sequence_number: 0, response: responseShell("in_progress") }); sseEvent(res, "response.in_progress", { type: "response.in_progress", sequence_number: 1, response: responseShell("in_progress") }); const messageItem = { type: "message", id: msgId, role: "assistant", status: "in_progress", content: [] }; sseEvent(res, "response.output_item.added", { type: "response.output_item.added", sequence_number: 2, output_index: 0, item: messageItem, }); // Stream the reply in small chunks so the renderer's delta path runs. const chunks = reply.match(/.{1,8}/gs) ?? [reply]; let seq = 3; for (const chunk of chunks) { sseEvent(res, "response.output_text.delta", { type: "response.output_text.delta", sequence_number: seq++, item_id: msgId, output_index: 0, content_index: 0, delta: chunk, }); } const completedItem = { type: "message", id: msgId, role: "assistant", status: "completed", content: [{ type: "output_text", text: reply, annotations: [] }], }; sseEvent(res, "response.output_text.done", { type: "response.output_text.done", sequence_number: seq++, item_id: msgId, output_index: 0, content_index: 0, text: reply, }); sseEvent(res, "response.output_item.done", { type: "response.output_item.done", sequence_number: seq++, output_index: 0, item: completedItem, }); const finalResponse = responseShell("completed", { output: [completedItem], usage: { input_tokens: 0, input_tokens_details: { cached_tokens: 0 }, output_tokens: 1, output_tokens_details: { reasoning_tokens: 0 }, total_tokens: 1, }, }); sseEvent(res, "response.completed", { type: "response.completed", sequence_number: seq++, response: finalResponse }); res.end(); }) .catch(() => { res.writeHead(400, { "content-type": "application/json; charset=utf-8" }); res.end(JSON.stringify({ error: { message: "bad request body" } })); }); return; } json({ error: { message: `mock relay: unhandled path ${route}` } }, 404); }; /** * Answer the ChatGPT-backend surface (`/mock/*`) with truthful local shapes. * * Every response is either the exact JSON the desktop main process would hand * over, or an empty-but-valid object. The point is that nothing errors and * nothing leaves the machine. */ export const serveMockBackend = (req, res, urlPath, opts = {}) => { opts.onRequest?.(`${req.method} ${urlPath}`); const json = (obj, status = 200) => { res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" }); res.end(JSON.stringify(obj)); }; const p = urlPath.replace(/^\/mock/, "") || "/"; // Cloud MCP endpoint (ChatGPT-hosted app connectors). 404 makes the app // server mark these unavailable immediately — a semantically empty 200 // would be retried and block thread startup on MCP enumeration. if (/^\/api\/codex\/ps\/mcp/.test(p)) { json({ error: { message: "cloud connectors are unavailable in a local build" } }, 404); return; } if (/^\/wham\/tasks\/list/.test(p)) { json({ items: [], cursor: null }); return; } if (/plugin/i.test(p)) { json({ schema_version: 1, fetched_at: new Date().toISOString(), plugins: [] }); return; } json({}); };