Files
codex-app-server/cloud/codex-ui-host/host/mock-model.mjs
T
host 3e0ff898e0
ci / test (push) Successful in 45m25s
feat: wire native Codex UI as /console entry, keep classic console at /console-classic
- Vendor codex-ui-stack host/ as cloud/codex-ui-host/ (original/ not vendored, 650MB)
- Bridge emulates 7 methods missing in codex 0.164.0-alpha.9, exposes 29
  experimental-protocol methods (150 -> 179), Linux binary candidates,
  read-only POST /__codex_git
- GET /console 302s to the UI host (:8095) when reachable, else falls back
  to the classic React console; GET /console-classic always serves it
- Alignment tables: docs/codex-native-ui-alignment.md
- Tests: test_console 7, new test_codex_ui_host 4, handler method pin 247 -> 249
2026-11-06 19:03:35 +00:00

224 lines
7.8 KiB
JavaScript

/**
* 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({});
};