mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-04 19:17:40 +00:00
382 lines
10 KiB
JavaScript
382 lines
10 KiB
JavaScript
import { createHash } from "node:crypto";
|
|
import fs from "node:fs";
|
|
import http from "node:http";
|
|
import { readPositiveIntEnv } from "./lib/env-limits.mjs";
|
|
import { readBody, writeJson, writeSse } from "./lib/mock-openai-http.mjs";
|
|
|
|
const port =
|
|
process.env.MOCK_PORT != null
|
|
? readPositiveIntEnv("MOCK_PORT")
|
|
: readPositiveIntEnv("OPENCLAW_MOCK_OPENAI_PORT");
|
|
const successMarker = process.env.SUCCESS_MARKER ?? "OPENCLAW_E2E_OK";
|
|
const requestLog = process.env.MOCK_REQUEST_LOG;
|
|
|
|
function responseEvents(text) {
|
|
const itemId = "msg_e2e_1";
|
|
return [
|
|
{
|
|
type: "response.output_item.added",
|
|
item: {
|
|
type: "message",
|
|
id: itemId,
|
|
role: "assistant",
|
|
content: [],
|
|
status: "in_progress",
|
|
},
|
|
},
|
|
{
|
|
type: "response.output_text.delta",
|
|
item_id: itemId,
|
|
output_index: 0,
|
|
content_index: 0,
|
|
delta: text,
|
|
},
|
|
{
|
|
type: "response.output_text.done",
|
|
item_id: itemId,
|
|
output_index: 0,
|
|
content_index: 0,
|
|
text,
|
|
},
|
|
{
|
|
type: "response.output_item.done",
|
|
item: {
|
|
type: "message",
|
|
id: itemId,
|
|
role: "assistant",
|
|
status: "completed",
|
|
content: [{ type: "output_text", text, annotations: [] }],
|
|
},
|
|
},
|
|
{
|
|
type: "response.completed",
|
|
response: {
|
|
id: "resp_e2e",
|
|
status: "completed",
|
|
output: [
|
|
{
|
|
type: "message",
|
|
id: itemId,
|
|
role: "assistant",
|
|
status: "completed",
|
|
content: [{ type: "output_text", text, annotations: [] }],
|
|
},
|
|
],
|
|
usage: {
|
|
input_tokens: 11,
|
|
output_tokens: 7,
|
|
total_tokens: 18,
|
|
input_tokens_details: { cached_tokens: 0 },
|
|
},
|
|
},
|
|
},
|
|
];
|
|
}
|
|
|
|
function buildMockFunctionCall(name, args) {
|
|
const serialized = JSON.stringify(args);
|
|
const suffix = createHash("sha256")
|
|
.update(name)
|
|
.update("\0")
|
|
.update(serialized)
|
|
.digest("hex")
|
|
.slice(0, 10);
|
|
const callId = `call_mock_${name}_${suffix}`;
|
|
const itemId = `fc_mock_${name}_${suffix}`;
|
|
const item = {
|
|
type: "function_call",
|
|
id: itemId,
|
|
call_id: callId,
|
|
name,
|
|
arguments: serialized,
|
|
};
|
|
return {
|
|
item,
|
|
itemId,
|
|
responseId: `resp_mock_${name}_${suffix}`,
|
|
serialized,
|
|
};
|
|
}
|
|
|
|
function toolCallEvents(name, args) {
|
|
const call = buildMockFunctionCall(name, args);
|
|
return [
|
|
{
|
|
type: "response.output_item.added",
|
|
item: {
|
|
type: "function_call",
|
|
id: call.itemId,
|
|
call_id: call.item.call_id,
|
|
name,
|
|
arguments: "",
|
|
},
|
|
},
|
|
{ type: "response.function_call_arguments.delta", delta: call.serialized },
|
|
{ type: "response.output_item.done", item: call.item },
|
|
{
|
|
type: "response.completed",
|
|
response: {
|
|
id: call.responseId,
|
|
status: "completed",
|
|
output: [call.item],
|
|
usage: {
|
|
input_tokens: 64,
|
|
output_tokens: 16,
|
|
total_tokens: 80,
|
|
input_tokens_details: { cached_tokens: 0 },
|
|
},
|
|
},
|
|
},
|
|
];
|
|
}
|
|
|
|
function writeResponsesEvents(res, stream, events) {
|
|
if (stream === false) {
|
|
const completed = events.find((event) => event.type === "response.completed");
|
|
writeJson(res, 200, {
|
|
id: completed?.response?.id ?? "resp_e2e",
|
|
object: "response",
|
|
status: "completed",
|
|
output: completed?.response?.output ?? [],
|
|
usage: completed?.response?.usage ?? {
|
|
input_tokens: 64,
|
|
output_tokens: 16,
|
|
total_tokens: 80,
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
writeSse(res, events);
|
|
}
|
|
|
|
function writeChatCompletion(res, stream, text = successMarker) {
|
|
if (stream) {
|
|
writeSse(res, [
|
|
{
|
|
id: "chatcmpl_e2e",
|
|
object: "chat.completion.chunk",
|
|
choices: [{ index: 0, delta: { role: "assistant", content: text } }],
|
|
},
|
|
{
|
|
id: "chatcmpl_e2e",
|
|
object: "chat.completion.chunk",
|
|
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
|
|
},
|
|
]);
|
|
return;
|
|
}
|
|
writeJson(res, 200, {
|
|
id: "chatcmpl_e2e",
|
|
object: "chat.completion",
|
|
choices: [{ index: 0, message: { role: "assistant", content: text }, finish_reason: "stop" }],
|
|
usage: { prompt_tokens: 11, completion_tokens: 7, total_tokens: 18 },
|
|
});
|
|
}
|
|
|
|
function writeImageGeneration(res) {
|
|
writeJson(res, 200, {
|
|
created: Math.floor(Date.now() / 1000),
|
|
data: [
|
|
{
|
|
b64_json:
|
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+yf7kAAAAASUVORK5CYII=",
|
|
mime_type: "image/png",
|
|
revised_prompt: "openclaw mock image",
|
|
},
|
|
],
|
|
});
|
|
}
|
|
|
|
function resolveResponseText(bodyText) {
|
|
const matches = Array.from(bodyText.matchAll(/\bOPENCLAW_E2E_OK(?:_\d+)?\b/gu));
|
|
return matches.at(-1)?.[0] ?? successMarker;
|
|
}
|
|
|
|
function collectText(value) {
|
|
if (typeof value === "string") {
|
|
return [value];
|
|
}
|
|
if (Array.isArray(value)) {
|
|
return value.flatMap((entry) => collectText(entry));
|
|
}
|
|
if (!value || typeof value !== "object") {
|
|
return [];
|
|
}
|
|
const texts = [];
|
|
for (const key of ["text", "content", "output"]) {
|
|
if (typeof value[key] === "string") {
|
|
texts.push(value[key]);
|
|
}
|
|
}
|
|
for (const nested of Object.values(value)) {
|
|
if (nested && typeof nested === "object") {
|
|
texts.push(...collectText(nested));
|
|
}
|
|
}
|
|
return texts;
|
|
}
|
|
|
|
function stringifyFunctionCallOutput(output) {
|
|
if (typeof output === "string") {
|
|
return output;
|
|
}
|
|
try {
|
|
return JSON.stringify(output);
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
function collectFunctionCallOutputText(body) {
|
|
const input = Array.isArray(body?.input) ? body.input : [];
|
|
return input
|
|
.filter((item) => item?.type === "function_call_output")
|
|
.map((item) => stringifyFunctionCallOutput(item.output))
|
|
.filter(Boolean)
|
|
.join("\n");
|
|
}
|
|
|
|
function hasDeclaredTool(bodyText, name) {
|
|
return new RegExp(`"name"\\s*:\\s*"${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}"`, "u").test(
|
|
bodyText,
|
|
);
|
|
}
|
|
|
|
function mcpCodeModeApiFileEvents(body, bodyText) {
|
|
const allText = collectText(body).join("\n");
|
|
if (!/mcp code mode api file qa check/i.test(allText)) {
|
|
return null;
|
|
}
|
|
const toolOutput = collectFunctionCallOutputText(body);
|
|
if (!toolOutput) {
|
|
if (!hasDeclaredTool(bodyText, "exec")) {
|
|
return null;
|
|
}
|
|
return toolCallEvents("exec", {
|
|
language: "javascript",
|
|
code: [
|
|
'const files = await API.list("mcp");',
|
|
'const root = await API.read("mcp/index.d.ts");',
|
|
'const api = await API.read("mcp/fixture.d.ts");',
|
|
'const result = await MCP.fixture.lookupNote({ id: "alpha" });',
|
|
"return {",
|
|
' marker: "MCP_CODE_MODE_FILE_TOOL_RESULT",',
|
|
" files: files.files.map((file) => file.path),",
|
|
" rootHasFixture: root.content.includes('fixture'),",
|
|
" headerHasLookup: api.content.includes('function lookupNote'),",
|
|
" resultText: result.content?.[0]?.text,",
|
|
" allHasMcp: ALL_TOOLS.some((tool) => tool.source === 'mcp'),",
|
|
"};",
|
|
].join("\n"),
|
|
});
|
|
}
|
|
if (
|
|
!/MCP_CODE_MODE_FILE_TOOL_RESULT/.test(toolOutput) ||
|
|
!/fixture-note-alpha/.test(toolOutput)
|
|
) {
|
|
return responseEvents(
|
|
"MCP_CODE_MODE_FILE_FAIL unclear=code-mode-exec-did-not-return-fixture-note",
|
|
);
|
|
}
|
|
return responseEvents(
|
|
"MCP_CODE_MODE_FILE_OK note=fixture-note-alpha unclear=none improvement=virtual-api-files-were-clear-and-needed-one-exec",
|
|
);
|
|
}
|
|
|
|
const server = http.createServer((req, res) => {
|
|
void (async () => {
|
|
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
if (req.method === "GET" && url.pathname === "/health") {
|
|
writeJson(res, 200, { ok: true });
|
|
return;
|
|
}
|
|
if (req.method === "GET" && url.pathname === "/v1/models") {
|
|
writeJson(res, 200, {
|
|
object: "list",
|
|
data: [{ id: "gpt-5.5", object: "model", owned_by: "openclaw-e2e" }],
|
|
});
|
|
return;
|
|
}
|
|
|
|
const bodyText = await readBody(req);
|
|
if (requestLog) {
|
|
fs.appendFileSync(
|
|
requestLog,
|
|
`${JSON.stringify({ method: req.method, path: url.pathname, body: bodyText })}\n`,
|
|
);
|
|
}
|
|
let body;
|
|
try {
|
|
body = bodyText ? JSON.parse(bodyText) : {};
|
|
} catch {
|
|
body = {};
|
|
}
|
|
|
|
if (req.method === "POST" && url.pathname === "/v1/responses") {
|
|
const codeModeEvents = mcpCodeModeApiFileEvents(body, bodyText);
|
|
if (codeModeEvents) {
|
|
writeResponsesEvents(res, body.stream, codeModeEvents);
|
|
return;
|
|
}
|
|
const responseText = resolveResponseText(bodyText);
|
|
if (body.stream === false) {
|
|
writeJson(res, 200, {
|
|
id: "resp_e2e",
|
|
object: "response",
|
|
status: "completed",
|
|
output: [
|
|
{
|
|
type: "message",
|
|
id: "msg_e2e_1",
|
|
role: "assistant",
|
|
status: "completed",
|
|
content: [{ type: "output_text", text: responseText, annotations: [] }],
|
|
},
|
|
],
|
|
usage: { input_tokens: 11, output_tokens: 7, total_tokens: 18 },
|
|
});
|
|
return;
|
|
}
|
|
writeSse(res, responseEvents(responseText));
|
|
return;
|
|
}
|
|
|
|
if (req.method === "POST" && url.pathname === "/v1/chat/completions") {
|
|
const responseText = resolveResponseText(bodyText);
|
|
writeChatCompletion(res, body.stream !== false, responseText);
|
|
return;
|
|
}
|
|
|
|
if (req.method === "POST" && url.pathname === "/v1/embeddings") {
|
|
const input = Array.isArray(body.input) ? body.input : [body.input ?? ""];
|
|
writeJson(res, 200, {
|
|
object: "list",
|
|
data: input.map((_, index) => ({
|
|
object: "embedding",
|
|
index,
|
|
embedding: [1, index / 100, 0, 0],
|
|
})),
|
|
model: body.model ?? "text-embedding-3-small",
|
|
usage: { prompt_tokens: input.length, total_tokens: input.length },
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (
|
|
req.method === "POST" &&
|
|
(url.pathname === "/v1/images/generations" || url.pathname === "/v1/images/edits")
|
|
) {
|
|
writeImageGeneration(res);
|
|
return;
|
|
}
|
|
|
|
writeJson(res, 404, {
|
|
error: { message: `unhandled mock route: ${req.method} ${url.pathname}` },
|
|
});
|
|
})();
|
|
});
|
|
|
|
server.listen(port, "127.0.0.1", () => {
|
|
console.log(`mock-openai listening on ${port}`);
|
|
});
|