ci / test (push) Successful in 45m25s
- 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
224 lines
7.8 KiB
JavaScript
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({});
|
|
};
|