mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-23 22:08:10 +00:00
Reject unsafe numeric Content-Length values in the OpenAI chat tools E2E client before waiting on the response stream. Also hardens Docker E2E heartbeat timing coverage after the exact-head release gate exposed a brittle zero-padded heartbeat assertion. Verification: direct mock gateway repro, docker heartbeat shell proof, autoreview clean, and exact-head CI release gate https://github.com/openclaw/openclaw/actions/runs/27843455246.
206 lines
6.1 KiB
JavaScript
206 lines
6.1 KiB
JavaScript
// Gateway client for OpenAI chat tools E2E scenarios.
|
|
import { readPositiveIntEnv, readTcpPortEnv } from "../env-limits.mjs";
|
|
|
|
const portText = process.env.PORT;
|
|
const token = process.env.OPENCLAW_GATEWAY_TOKEN;
|
|
const backendModel = process.env.MODEL_REF || "openai/gpt-5.4-mini";
|
|
|
|
const timeoutSeconds = readPositiveIntEnv("OPENCLAW_OPENAI_CHAT_TOOLS_TIMEOUT_SECONDS", 180);
|
|
const maxBodyBytes = readPositiveIntEnv("OPENCLAW_OPENAI_CHAT_TOOLS_MAX_BODY_BYTES", 1048576);
|
|
|
|
if (!portText || !token) {
|
|
throw new Error("missing PORT/OPENCLAW_GATEWAY_TOKEN");
|
|
}
|
|
const port = readTcpPortEnv("PORT", portText);
|
|
if (!Number.isFinite(timeoutSeconds) || timeoutSeconds <= 0) {
|
|
throw new Error(`invalid OPENCLAW_OPENAI_CHAT_TOOLS_TIMEOUT_SECONDS: ${timeoutSeconds}`);
|
|
}
|
|
if (!Number.isFinite(maxBodyBytes) || maxBodyBytes <= 0) {
|
|
throw new Error(`invalid OPENCLAW_OPENAI_CHAT_TOOLS_MAX_BODY_BYTES: ${maxBodyBytes}`);
|
|
}
|
|
|
|
function cancelReaderSoon(reader) {
|
|
void Promise.resolve()
|
|
.then(() => reader.cancel())
|
|
.catch(() => undefined);
|
|
}
|
|
|
|
async function readResponseChunk(reader, timeoutPromise, markCanceled) {
|
|
const readPromise = reader.read();
|
|
if (!timeoutPromise) {
|
|
return await readPromise;
|
|
}
|
|
|
|
let waitingForRead = true;
|
|
const timeoutReadPromise = timeoutPromise.catch((error) => {
|
|
if (waitingForRead) {
|
|
markCanceled();
|
|
cancelReaderSoon(reader);
|
|
}
|
|
throw error;
|
|
});
|
|
|
|
try {
|
|
return await Promise.race([readPromise, timeoutReadPromise]);
|
|
} finally {
|
|
waitingForRead = false;
|
|
}
|
|
}
|
|
|
|
async function readBoundedResponseText(response, byteLimit, timeoutPromise) {
|
|
const contentLength = response.headers?.get?.("content-length");
|
|
if (contentLength && /^\d+$/u.test(contentLength)) {
|
|
const parsedContentLength = Number(contentLength);
|
|
if (!Number.isSafeInteger(parsedContentLength) || parsedContentLength > byteLimit) {
|
|
await response.body?.cancel().catch(() => undefined);
|
|
throw new Error(`chat completions response body exceeded ${byteLimit} bytes`);
|
|
}
|
|
}
|
|
|
|
const reader = response.body?.getReader();
|
|
if (!reader) {
|
|
return "";
|
|
}
|
|
const chunks = [];
|
|
let totalBytes = 0;
|
|
let canceled = false;
|
|
try {
|
|
for (;;) {
|
|
const { done, value } = await readResponseChunk(reader, timeoutPromise, () => {
|
|
canceled = true;
|
|
});
|
|
if (done) {
|
|
break;
|
|
}
|
|
totalBytes += value.byteLength;
|
|
if (totalBytes > byteLimit) {
|
|
canceled = true;
|
|
await reader.cancel();
|
|
throw new Error(`chat completions response body exceeded ${byteLimit} bytes`);
|
|
}
|
|
chunks.push(Buffer.from(value));
|
|
}
|
|
} finally {
|
|
if (!canceled) {
|
|
reader.releaseLock();
|
|
}
|
|
}
|
|
return Buffer.concat(chunks, totalBytes).toString("utf8");
|
|
}
|
|
|
|
const controller = new AbortController();
|
|
const timeoutError = new Error(`chat completions request timed out after ${timeoutSeconds}s`);
|
|
let timeout;
|
|
const timeoutPromise = new Promise((_, reject) => {
|
|
timeout = setTimeout(() => {
|
|
controller.abort(timeoutError);
|
|
reject(timeoutError);
|
|
}, timeoutSeconds * 1000);
|
|
timeout.unref?.();
|
|
});
|
|
const started = Date.now();
|
|
let response;
|
|
let text;
|
|
try {
|
|
response = await Promise.race([
|
|
fetch(`http://127.0.0.1:${port}/v1/chat/completions`, {
|
|
method: "POST",
|
|
headers: {
|
|
authorization: `Bearer ${token}`,
|
|
"content-type": "application/json",
|
|
"x-openclaw-model": backendModel,
|
|
},
|
|
body: JSON.stringify({
|
|
model: "openclaw",
|
|
stream: false,
|
|
messages: [
|
|
{
|
|
role: "user",
|
|
content:
|
|
"Use the get_weather tool exactly once for Paris, France. Return the tool call only.",
|
|
},
|
|
],
|
|
tool_choice: "auto",
|
|
tools: [
|
|
{
|
|
type: "function",
|
|
function: {
|
|
name: "get_weather",
|
|
description: "Return weather for a city.",
|
|
strict: true,
|
|
parameters: {
|
|
type: "object",
|
|
additionalProperties: false,
|
|
properties: {
|
|
city: { type: "string", description: "City and country." },
|
|
},
|
|
required: ["city"],
|
|
},
|
|
},
|
|
},
|
|
],
|
|
}),
|
|
signal: controller.signal,
|
|
}),
|
|
timeoutPromise,
|
|
]);
|
|
text = await readBoundedResponseText(response, maxBodyBytes, timeoutPromise);
|
|
} finally {
|
|
clearTimeout(timeout);
|
|
}
|
|
|
|
let body;
|
|
try {
|
|
body = text ? JSON.parse(text) : {};
|
|
} catch {
|
|
throw new Error(`non-JSON response ${response.status}: ${text}`);
|
|
}
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`chat completions request failed ${response.status}: ${JSON.stringify(body)}`);
|
|
}
|
|
|
|
const choice = body.choices?.[0];
|
|
const toolCalls = choice?.message?.tool_calls;
|
|
if (choice?.finish_reason !== "tool_calls") {
|
|
throw new Error(`expected finish_reason tool_calls: ${JSON.stringify(body)}`);
|
|
}
|
|
const messageContent = choice?.message?.content;
|
|
const hasVisibleContent =
|
|
(typeof messageContent === "string" && messageContent.trim().length > 0) ||
|
|
(Array.isArray(messageContent) && messageContent.length > 0) ||
|
|
(messageContent !== undefined &&
|
|
messageContent !== null &&
|
|
typeof messageContent !== "string" &&
|
|
!Array.isArray(messageContent));
|
|
if (hasVisibleContent) {
|
|
throw new Error(`expected tool call only response: ${JSON.stringify(choice.message)}`);
|
|
}
|
|
if (!Array.isArray(toolCalls) || toolCalls.length !== 1) {
|
|
throw new Error(`expected exactly one tool call: ${JSON.stringify(body)}`);
|
|
}
|
|
const [toolCall] = toolCalls;
|
|
if (toolCall?.type !== "function" || toolCall?.function?.name !== "get_weather") {
|
|
throw new Error(`unexpected tool call: ${JSON.stringify(toolCall)}`);
|
|
}
|
|
|
|
let args;
|
|
try {
|
|
args = JSON.parse(toolCall.function.arguments || "{}");
|
|
} catch {
|
|
throw new Error(`tool arguments were not valid JSON: ${toolCall.function.arguments}`);
|
|
}
|
|
if (typeof args.city !== "string" || !/paris/i.test(args.city)) {
|
|
throw new Error(`expected Paris city argument: ${JSON.stringify(args)}`);
|
|
}
|
|
|
|
console.log(
|
|
JSON.stringify({
|
|
ok: true,
|
|
elapsedMs: Date.now() - started,
|
|
finishReason: choice.finish_reason,
|
|
toolName: toolCall.function.name,
|
|
args,
|
|
}),
|
|
);
|