mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-10 05:52:51 +00:00
Enforce OpenAI-compatible `tool_choice` contracts for Gateway HTTP Chat Completions and Responses client function tools. - Add shared request normalization and post-run enforcement for required and pinned client function tool choices. - Buffer streaming output until the tool-choice contract is satisfied, so failed runs do not leak partial assistant prose. - Document the client-function-tool scope and add regression coverage for Chat/Responses success and failure cases. Thanks @Lellansin for the contribution. Proof: exact-head CI passed for `79fa0947360d307cf4ecffe713489cdf5db61093` in run `26714604449`; focused gateway tests passed locally.
2599 lines
84 KiB
TypeScript
2599 lines
84 KiB
TypeScript
import fs from "node:fs/promises";
|
|
import http from "node:http";
|
|
import path from "node:path";
|
|
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
|
import { createClientToolNameConflictError } from "../agents/agent-tool-definition-adapter.js";
|
|
import {
|
|
createStubSessionHarness,
|
|
emitAssistantTextDelta,
|
|
} from "../agents/embedded-agent-subscribe.e2e-harness.js";
|
|
import { subscribeEmbeddedAgentSession } from "../agents/embedded-agent-subscribe.js";
|
|
import { FailoverError } from "../agents/failover-error.js";
|
|
import { HISTORY_CONTEXT_MARKER } from "../auto-reply/reply/history.js";
|
|
import { CURRENT_MESSAGE_MARKER } from "../auto-reply/reply/mentions.js";
|
|
import { emitAgentEvent } from "../infra/agent-events.js";
|
|
import { buildAssistantDeltaResult } from "./test-helpers.agent-results.js";
|
|
import {
|
|
agentCommand,
|
|
getFreePort,
|
|
installGatewayTestHooks,
|
|
startGatewayServerWithRetries,
|
|
testState,
|
|
withGatewayServer,
|
|
} from "./test-helpers.js";
|
|
|
|
installGatewayTestHooks({ scope: "suite" });
|
|
|
|
let startGatewayServer: typeof import("./server.js").startGatewayServer;
|
|
let enabledServer: Awaited<ReturnType<typeof startServer>>;
|
|
let enabledPort: number;
|
|
|
|
beforeAll(async () => {
|
|
({ startGatewayServer } = await import("./server.js"));
|
|
const started = await startGatewayServerWithRetries({
|
|
port: await getFreePort(),
|
|
opts: {
|
|
host: "127.0.0.1",
|
|
auth: { mode: "none" },
|
|
controlUiEnabled: false,
|
|
openAiChatCompletionsEnabled: true,
|
|
},
|
|
});
|
|
enabledPort = started.port;
|
|
enabledServer = started.server;
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await enabledServer?.close({ reason: "openai http enabled suite done" });
|
|
});
|
|
|
|
async function startServer(port: number, opts?: { openAiChatCompletionsEnabled?: boolean }) {
|
|
return await startGatewayServer(port, {
|
|
host: "127.0.0.1",
|
|
auth: { mode: "none" },
|
|
controlUiEnabled: false,
|
|
openAiChatCompletionsEnabled: opts?.openAiChatCompletionsEnabled ?? true,
|
|
});
|
|
}
|
|
|
|
async function startTokenServer(port: number, opts?: { openAiChatCompletionsEnabled?: boolean }) {
|
|
return await startGatewayServer(port, {
|
|
host: "127.0.0.1",
|
|
auth: { mode: "token", token: "secret" },
|
|
controlUiEnabled: false,
|
|
openAiChatCompletionsEnabled: opts?.openAiChatCompletionsEnabled ?? true,
|
|
});
|
|
}
|
|
|
|
async function writeGatewayConfig(config: Record<string, unknown>) {
|
|
const configPath = process.env.OPENCLAW_CONFIG_PATH;
|
|
if (!configPath) {
|
|
throw new Error("OPENCLAW_CONFIG_PATH is required for gateway config tests");
|
|
}
|
|
await fs.mkdir(path.dirname(configPath), { recursive: true });
|
|
await fs.writeFile(configPath, JSON.stringify(config, null, 2), "utf-8");
|
|
}
|
|
|
|
async function postChatCompletions(port: number, body: unknown, headers?: Record<string, string>) {
|
|
const res = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, {
|
|
method: "POST",
|
|
headers: {
|
|
"content-type": "application/json",
|
|
"x-openclaw-scopes": "operator.write",
|
|
...headers,
|
|
},
|
|
body: JSON.stringify(body),
|
|
});
|
|
return res;
|
|
}
|
|
|
|
function parseSseDataLines(text: string): string[] {
|
|
return text
|
|
.split("\n")
|
|
.map((line) => line.trim())
|
|
.filter((line) => line.startsWith("data: "))
|
|
.map((line) => line.slice("data: ".length));
|
|
}
|
|
|
|
type FirstAgentCommandOptions = {
|
|
clientTools?: Array<{
|
|
function?: {
|
|
description?: string;
|
|
name?: string;
|
|
parameters?: Record<string, unknown>;
|
|
strict?: boolean;
|
|
};
|
|
type?: string;
|
|
}>;
|
|
extraSystemPrompt?: string;
|
|
images?: Array<{ data: string; mimeType: string; type: string }>;
|
|
message?: string;
|
|
messageChannel?: string;
|
|
model?: string;
|
|
sessionKey?: string;
|
|
streamParams?: {
|
|
frequencyPenalty?: number;
|
|
maxTokens?: number;
|
|
presencePenalty?: number;
|
|
responseFormat?: Record<string, unknown>;
|
|
seed?: number;
|
|
stop?: string[];
|
|
temperature?: number;
|
|
topP?: number;
|
|
};
|
|
};
|
|
|
|
function firstAgentCommandOptions() {
|
|
return agentCommand.mock.calls.at(0)?.[0] as FirstAgentCommandOptions | undefined;
|
|
}
|
|
|
|
describe("OpenAI-compatible HTTP API (e2e)", () => {
|
|
it("handles request validation and routing", async () => {
|
|
const port = enabledPort;
|
|
const mockAgentOnce = (payloads: Array<{ text: string }>) => {
|
|
agentCommand.mockClear();
|
|
agentCommand.mockResolvedValueOnce({ payloads } as never);
|
|
};
|
|
const expectAgentSessionKeyMatch = async (request: {
|
|
body: unknown;
|
|
headers?: Record<string, string>;
|
|
matcher: RegExp;
|
|
}) => {
|
|
mockAgentOnce([{ text: "hello" }]);
|
|
const res = await postChatCompletions(port, request.body, request.headers);
|
|
expect(res.status).toBe(200);
|
|
expect(agentCommand).toHaveBeenCalledTimes(1);
|
|
expect(firstAgentCommandOptions()?.sessionKey ?? "").toMatch(request.matcher);
|
|
await res.text();
|
|
};
|
|
const expectMessageContext = (
|
|
message: string,
|
|
expected: { history: string[]; current: string[] },
|
|
) => {
|
|
expect(message).toContain(HISTORY_CONTEXT_MARKER);
|
|
for (const line of expected.history) {
|
|
expect(message).toContain(line);
|
|
}
|
|
expect(message).toContain(CURRENT_MESSAGE_MARKER);
|
|
for (const line of expected.current) {
|
|
expect(message).toContain(line);
|
|
}
|
|
};
|
|
const getFirstAgentCall = () => firstAgentCommandOptions();
|
|
const getFirstAgentMessage = () => getFirstAgentCall()?.message ?? "";
|
|
const expectInvalidRequestNoDispatch = async (messages: unknown[]) => {
|
|
agentCommand.mockClear();
|
|
const res = await postChatCompletions(port, {
|
|
model: "openclaw",
|
|
messages,
|
|
});
|
|
expect(res.status).toBe(400);
|
|
const json = (await res.json()) as Record<string, unknown>;
|
|
expect((json.error as Record<string, unknown> | undefined)?.type).toBe(
|
|
"invalid_request_error",
|
|
);
|
|
expect(agentCommand).toHaveBeenCalledTimes(0);
|
|
};
|
|
const postSyncUserMessage = async (message: string) => {
|
|
const res = await postChatCompletions(port, {
|
|
stream: false,
|
|
model: "openclaw",
|
|
messages: [{ role: "user", content: message }],
|
|
});
|
|
expect(res.status).toBe(200);
|
|
return (await res.json()) as Record<string, unknown>;
|
|
};
|
|
|
|
try {
|
|
{
|
|
const res = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, {
|
|
method: "GET",
|
|
headers: { authorization: "Bearer secret" },
|
|
});
|
|
expect(res.status).toBe(405);
|
|
await res.text();
|
|
}
|
|
|
|
{
|
|
mockAgentOnce([{ text: "hello" }]);
|
|
const res = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, {
|
|
method: "POST",
|
|
headers: {
|
|
"content-type": "application/json",
|
|
},
|
|
body: JSON.stringify({ messages: [{ role: "user", content: "hi" }] }),
|
|
});
|
|
expect(res.status).toBe(200);
|
|
expect(agentCommand).toHaveBeenCalledTimes(1);
|
|
expect(getFirstAgentCall()?.messageChannel).toBe("webchat");
|
|
await res.text();
|
|
}
|
|
|
|
await expectAgentSessionKeyMatch({
|
|
body: { model: "openclaw", messages: [{ role: "user", content: "hi" }] },
|
|
headers: { "x-openclaw-agent-id": "beta" },
|
|
matcher: /^agent:beta:/,
|
|
});
|
|
|
|
await expectAgentSessionKeyMatch({
|
|
body: {
|
|
model: "openclaw/beta",
|
|
messages: [{ role: "user", content: "hi" }],
|
|
},
|
|
matcher: /^agent:beta:/,
|
|
});
|
|
|
|
await expectAgentSessionKeyMatch({
|
|
body: {
|
|
model: "openclaw/default",
|
|
messages: [{ role: "user", content: "hi" }],
|
|
},
|
|
matcher: /^agent:main:/,
|
|
});
|
|
|
|
{
|
|
mockAgentOnce([{ text: "hello" }]);
|
|
const res = await postChatCompletions(
|
|
port,
|
|
{ model: "openclaw", messages: [{ role: "user", content: "hi" }] },
|
|
{
|
|
"x-openclaw-agent-id": "beta",
|
|
"x-openclaw-session-key": "agent:beta:openai:custom",
|
|
},
|
|
);
|
|
expect(res.status).toBe(200);
|
|
|
|
expect(firstAgentCommandOptions()?.sessionKey).toBe("agent:beta:openai:custom");
|
|
await res.text();
|
|
}
|
|
|
|
{
|
|
mockAgentOnce([{ text: "hello" }]);
|
|
const res = await postChatCompletions(port, {
|
|
user: "alice",
|
|
model: "openclaw",
|
|
messages: [{ role: "user", content: "hi" }],
|
|
});
|
|
expect(res.status).toBe(200);
|
|
|
|
expect(firstAgentCommandOptions()?.sessionKey ?? "").toContain("openai-user:alice");
|
|
await res.text();
|
|
}
|
|
|
|
{
|
|
mockAgentOnce([{ text: "hello" }]);
|
|
const res = await postChatCompletions(
|
|
port,
|
|
{
|
|
model: "openclaw",
|
|
messages: [{ role: "user", content: "hi" }],
|
|
},
|
|
{ "x-openclaw-message-channel": "custom-client-channel" },
|
|
);
|
|
expect(res.status).toBe(200);
|
|
expect(getFirstAgentCall()?.messageChannel).toBe("custom-client-channel");
|
|
await res.text();
|
|
}
|
|
|
|
{
|
|
mockAgentOnce([{ text: "hello" }]);
|
|
const res = await postChatCompletions(
|
|
port,
|
|
{
|
|
model: "openclaw",
|
|
messages: [{ role: "user", content: "hi" }],
|
|
},
|
|
{
|
|
"x-openclaw-model": "openai/gpt-5.4",
|
|
},
|
|
);
|
|
expect(res.status).toBe(200);
|
|
expect(firstAgentCommandOptions()?.model).toBe("openai/gpt-5.4");
|
|
await res.text();
|
|
}
|
|
|
|
{
|
|
await writeGatewayConfig({
|
|
agents: {
|
|
defaults: {
|
|
model: { primary: "openai/gpt-5.4" },
|
|
models: {
|
|
"openai/gpt-5.4": {},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
mockAgentOnce([{ text: "hello" }]);
|
|
const res = await postChatCompletions(
|
|
port,
|
|
{
|
|
model: "openclaw",
|
|
messages: [{ role: "user", content: "hi" }],
|
|
},
|
|
{
|
|
"x-openclaw-model": "gpt-5.4",
|
|
},
|
|
);
|
|
expect(res.status).toBe(200);
|
|
expect(firstAgentCommandOptions()?.model).toBe("gpt-5.4");
|
|
await res.text();
|
|
await writeGatewayConfig({});
|
|
}
|
|
|
|
{
|
|
agentCommand.mockClear();
|
|
const res = await postChatCompletions(port, {
|
|
model: "openai/",
|
|
messages: [{ role: "user", content: "hi" }],
|
|
});
|
|
expect(res.status).toBe(400);
|
|
const json = (await res.json()) as { error?: { type?: string; message?: string } };
|
|
expect(json.error?.type).toBe("invalid_request_error");
|
|
expect(json.error?.message).toBe(
|
|
"Invalid `model`. Use `openclaw` or `openclaw/<agentId>`.",
|
|
);
|
|
expect(agentCommand).toHaveBeenCalledTimes(0);
|
|
}
|
|
|
|
{
|
|
agentCommand.mockClear();
|
|
const res = await postChatCompletions(
|
|
port,
|
|
{
|
|
model: "openclaw",
|
|
messages: [{ role: "user", content: "hi" }],
|
|
},
|
|
{ "x-openclaw-model": "openai/" },
|
|
);
|
|
expect(res.status).toBe(400);
|
|
const json = (await res.json()) as { error?: { type?: string; message?: string } };
|
|
expect(json.error?.type).toBe("invalid_request_error");
|
|
expect(json.error?.message).toBe("Invalid `x-openclaw-model`.");
|
|
expect(agentCommand).toHaveBeenCalledTimes(0);
|
|
}
|
|
|
|
{
|
|
mockAgentOnce([{ text: "hello" }]);
|
|
const res = await postChatCompletions(port, {
|
|
model: "openclaw",
|
|
messages: [
|
|
{
|
|
role: "user",
|
|
content: [
|
|
{ type: "text", text: "hello" },
|
|
{ type: "input_text", text: "world" },
|
|
],
|
|
},
|
|
],
|
|
});
|
|
expect(res.status).toBe(200);
|
|
|
|
expect(firstAgentCommandOptions()?.message).toBe("hello\nworld");
|
|
await res.text();
|
|
}
|
|
|
|
{
|
|
const imageData = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAA";
|
|
mockAgentOnce([{ text: "looks good" }]);
|
|
const res = await postChatCompletions(port, {
|
|
model: "openclaw",
|
|
messages: [
|
|
{
|
|
role: "user",
|
|
content: [
|
|
{ type: "text", text: "describe this" },
|
|
{
|
|
type: "image_url",
|
|
image_url: { url: `data:image/png;base64,${imageData}` },
|
|
},
|
|
],
|
|
},
|
|
],
|
|
});
|
|
expect(res.status).toBe(200);
|
|
|
|
const firstCall = getFirstAgentCall();
|
|
expect(firstCall?.message).toBe("describe this");
|
|
expect(firstCall?.images).toEqual([
|
|
{ type: "image", data: imageData, mimeType: "image/png" },
|
|
]);
|
|
await res.text();
|
|
}
|
|
|
|
{
|
|
const imageData = "QUJDRA==";
|
|
mockAgentOnce([{ text: "supports data-uri params" }]);
|
|
const res = await postChatCompletions(port, {
|
|
model: "openclaw",
|
|
messages: [
|
|
{
|
|
role: "user",
|
|
content: [
|
|
{ type: "text", text: "with metadata params" },
|
|
{
|
|
type: "image_url",
|
|
image_url: { url: `data:image/png;charset=utf-8;base64,${imageData}` },
|
|
},
|
|
],
|
|
},
|
|
],
|
|
});
|
|
expect(res.status).toBe(200);
|
|
|
|
const firstCall = getFirstAgentCall();
|
|
expect(firstCall?.images).toEqual([
|
|
{ type: "image", data: imageData, mimeType: "image/png" },
|
|
]);
|
|
await res.text();
|
|
}
|
|
|
|
await expectInvalidRequestNoDispatch([
|
|
{
|
|
role: "user",
|
|
content: [
|
|
{
|
|
type: "image_url",
|
|
image_url: { url: "https://example.com/image.png" },
|
|
},
|
|
],
|
|
},
|
|
]);
|
|
|
|
{
|
|
mockAgentOnce([{ text: "I can see the image" }]);
|
|
const res = await postChatCompletions(port, {
|
|
model: "openclaw",
|
|
messages: [
|
|
{
|
|
role: "user",
|
|
content: [
|
|
{
|
|
type: "image_url",
|
|
image_url: { url: "data:image/jpeg;base64,QUJDRA==" },
|
|
},
|
|
],
|
|
},
|
|
],
|
|
});
|
|
expect(res.status).toBe(200);
|
|
|
|
const firstCall = getFirstAgentCall();
|
|
expect(firstCall?.message).toContain("User sent image(s) with no text.");
|
|
expect(firstCall?.images).toEqual([
|
|
{ type: "image", data: "QUJDRA==", mimeType: "image/jpeg" },
|
|
]);
|
|
await res.text();
|
|
}
|
|
|
|
{
|
|
mockAgentOnce([{ text: "follow up answer" }]);
|
|
const res = await postChatCompletions(port, {
|
|
model: "openclaw",
|
|
messages: [
|
|
{
|
|
role: "user",
|
|
content: [
|
|
{ type: "image_url", image_url: { url: "data:image/png;base64,QUJDRA==" } },
|
|
],
|
|
},
|
|
{ role: "assistant", content: "I can see it." },
|
|
{ role: "user", content: "What color was it?" },
|
|
],
|
|
});
|
|
expect(res.status).toBe(200);
|
|
|
|
const firstCall = getFirstAgentCall();
|
|
expect(firstCall?.images).toBeUndefined();
|
|
expect(firstCall?.message ?? "").not.toContain("User sent image(s) with no text.");
|
|
await res.text();
|
|
}
|
|
|
|
{
|
|
mockAgentOnce([{ text: "latest image only" }]);
|
|
const res = await postChatCompletions(port, {
|
|
model: "openclaw",
|
|
messages: [
|
|
{
|
|
role: "user",
|
|
content: [
|
|
{ type: "text", text: "first" },
|
|
{ type: "image_url", image_url: { url: "data:image/png;base64,QUFBQQ==" } },
|
|
],
|
|
},
|
|
{ role: "assistant", content: "noted" },
|
|
{
|
|
role: "user",
|
|
content: [
|
|
{ type: "text", text: "second" },
|
|
{ type: "image_url", image_url: { url: "data:image/png;base64,QkJCQg==" } },
|
|
],
|
|
},
|
|
],
|
|
});
|
|
expect(res.status).toBe(200);
|
|
|
|
const firstCall = getFirstAgentCall();
|
|
expect(firstCall?.images).toEqual([
|
|
{ type: "image", data: "QkJCQg==", mimeType: "image/png" },
|
|
]);
|
|
await res.text();
|
|
}
|
|
|
|
{
|
|
const largeMessage = "x".repeat(1_200_000);
|
|
mockAgentOnce([{ text: "accepted" }]);
|
|
const res = await postChatCompletions(port, {
|
|
model: "openclaw",
|
|
messages: [{ role: "user", content: largeMessage }],
|
|
});
|
|
expect(res.status).toBe(200);
|
|
await res.text();
|
|
}
|
|
|
|
await expectInvalidRequestNoDispatch([
|
|
{
|
|
role: "user",
|
|
content: [
|
|
{
|
|
type: "image_url",
|
|
image_url: { url: "data:application/pdf;base64,QUJDRA==" },
|
|
},
|
|
],
|
|
},
|
|
]);
|
|
|
|
{
|
|
const manyImageParts = Array.from({ length: 9 }).map(() => ({
|
|
type: "image_url",
|
|
image_url: { url: "data:image/png;base64,QUJDRA==" },
|
|
}));
|
|
await expectInvalidRequestNoDispatch([
|
|
{
|
|
role: "user",
|
|
content: manyImageParts,
|
|
},
|
|
]);
|
|
}
|
|
|
|
{
|
|
mockAgentOnce([{ text: "I am Claude" }]);
|
|
const res = await postChatCompletions(port, {
|
|
model: "openclaw",
|
|
messages: [
|
|
{ role: "system", content: "You are a helpful assistant." },
|
|
{ role: "user", content: "Hello, who are you?" },
|
|
{ role: "assistant", content: "I am Claude." },
|
|
{ role: "user", content: "What did I just ask you?" },
|
|
],
|
|
});
|
|
expect(res.status).toBe(200);
|
|
|
|
const message = getFirstAgentMessage();
|
|
expectMessageContext(message, {
|
|
history: ["User: Hello, who are you?", "Assistant: I am Claude."],
|
|
current: ["User: What did I just ask you?"],
|
|
});
|
|
await res.text();
|
|
}
|
|
|
|
{
|
|
mockAgentOnce([{ text: "hello" }]);
|
|
const res = await postChatCompletions(port, {
|
|
model: "openclaw",
|
|
messages: [
|
|
{ role: "system", content: "You are a helpful assistant." },
|
|
{ role: "user", content: "Hello" },
|
|
],
|
|
});
|
|
expect(res.status).toBe(200);
|
|
|
|
const message = getFirstAgentMessage();
|
|
expect(message).not.toContain(HISTORY_CONTEXT_MARKER);
|
|
expect(message).not.toContain(CURRENT_MESSAGE_MARKER);
|
|
expect(message).toBe("Hello");
|
|
await res.text();
|
|
}
|
|
|
|
{
|
|
mockAgentOnce([{ text: "hello" }]);
|
|
const res = await postChatCompletions(port, {
|
|
model: "openclaw",
|
|
messages: [
|
|
{ role: "developer", content: "You are a helpful assistant." },
|
|
{ role: "user", content: "Hello" },
|
|
],
|
|
});
|
|
expect(res.status).toBe(200);
|
|
|
|
const extraSystemPrompt = getFirstAgentCall()?.extraSystemPrompt ?? "";
|
|
expect(extraSystemPrompt).toBe("You are a helpful assistant.");
|
|
await res.text();
|
|
}
|
|
|
|
{
|
|
mockAgentOnce([{ text: "ok" }]);
|
|
const res = await postChatCompletions(port, {
|
|
model: "openclaw",
|
|
messages: [
|
|
{ role: "system", content: "You are a helpful assistant." },
|
|
{ role: "user", content: "What's the weather?" },
|
|
{ role: "assistant", content: "Checking the weather." },
|
|
{ role: "tool", content: "Sunny, 70F." },
|
|
],
|
|
});
|
|
expect(res.status).toBe(200);
|
|
|
|
const message = getFirstAgentMessage();
|
|
expectMessageContext(message, {
|
|
history: ["User: What's the weather?", "Assistant: Checking the weather."],
|
|
current: ["Tool: Sunny, 70F."],
|
|
});
|
|
await res.text();
|
|
}
|
|
|
|
{
|
|
mockAgentOnce([{ text: "tool follow-up ok" }]);
|
|
const res = await postChatCompletions(port, {
|
|
model: "openclaw",
|
|
messages: [
|
|
{
|
|
role: "user",
|
|
content: [
|
|
{ type: "text", text: "look at this" },
|
|
{ type: "image_url", image_url: { url: "https://example.com/image.png" } },
|
|
],
|
|
},
|
|
{ role: "assistant", content: "Checking the image." },
|
|
{ role: "tool", content: "Vision tool says it is blue." },
|
|
],
|
|
});
|
|
expect(res.status).toBe(200);
|
|
|
|
const firstCall = getFirstAgentCall();
|
|
expect(firstCall?.images).toBeUndefined();
|
|
const message = getFirstAgentMessage();
|
|
expectMessageContext(message, {
|
|
history: ["User: look at this", "Assistant: Checking the image."],
|
|
current: ["Tool: Vision tool says it is blue."],
|
|
});
|
|
expect(message).not.toContain("User sent image(s) with no text.");
|
|
await res.text();
|
|
}
|
|
|
|
{
|
|
mockAgentOnce([{ text: "tool choice none" }]);
|
|
const res = await postChatCompletions(port, {
|
|
model: "openclaw",
|
|
tool_choice: "none",
|
|
tools: [
|
|
{
|
|
type: "function",
|
|
function: {
|
|
name: "get_time",
|
|
description: "Get current time",
|
|
parameters: { type: "object", properties: {} },
|
|
},
|
|
},
|
|
],
|
|
messages: [{ role: "user", content: "time?" }],
|
|
});
|
|
expect(res.status).toBe(200);
|
|
const firstCall = getFirstAgentCall();
|
|
expect(firstCall?.clientTools).toBeUndefined();
|
|
await res.text();
|
|
}
|
|
|
|
{
|
|
mockAgentOnce([{ text: "tool choice auto" }]);
|
|
const res = await postChatCompletions(port, {
|
|
model: "openclaw",
|
|
tool_choice: "auto",
|
|
tools: [
|
|
{
|
|
type: "function",
|
|
function: {
|
|
name: "get_time",
|
|
description: "Get current time",
|
|
parameters: { type: "object", properties: {} },
|
|
strict: true,
|
|
},
|
|
},
|
|
],
|
|
messages: [{ role: "user", content: "time?" }],
|
|
});
|
|
expect(res.status).toBe(200);
|
|
const firstCall = getFirstAgentCall();
|
|
const clientTools = firstCall?.clientTools ?? [];
|
|
expect(clientTools).toHaveLength(1);
|
|
expect(clientTools[0]?.type).toBe("function");
|
|
expect(clientTools[0]?.function?.name).toBe("get_time");
|
|
expect(clientTools[0]?.function?.strict).toBe(true);
|
|
expect(firstCall).not.toHaveProperty("toolsAllow");
|
|
await res.text();
|
|
}
|
|
|
|
{
|
|
agentCommand.mockClear();
|
|
agentCommand.mockResolvedValueOnce({
|
|
payloads: [{ text: "tool choice function" }],
|
|
meta: {
|
|
stopReason: "tool_calls",
|
|
pendingToolCalls: [
|
|
{
|
|
id: "call_1",
|
|
name: "get_weather",
|
|
arguments: '{"city":"Taipei"}',
|
|
},
|
|
],
|
|
},
|
|
} as never);
|
|
const res = await postChatCompletions(port, {
|
|
model: "openclaw",
|
|
tool_choice: { type: "function", function: { name: "get_weather" } },
|
|
tools: [
|
|
{
|
|
type: "function",
|
|
function: {
|
|
name: "get_time",
|
|
description: "Get current time",
|
|
parameters: { type: "object", properties: {} },
|
|
},
|
|
},
|
|
{
|
|
type: "function",
|
|
function: {
|
|
name: "get_weather",
|
|
description: "Get current weather",
|
|
parameters: {
|
|
type: "object",
|
|
properties: { city: { type: "string" } },
|
|
required: ["city"],
|
|
},
|
|
},
|
|
},
|
|
],
|
|
messages: [{ role: "user", content: "weather?" }],
|
|
});
|
|
expect(res.status).toBe(200);
|
|
const firstCall = getFirstAgentCall();
|
|
const clientTools = firstCall?.clientTools ?? [];
|
|
expect(clientTools).toHaveLength(1);
|
|
expect(clientTools[0]?.function?.name).toBe("get_weather");
|
|
expect(firstCall?.extraSystemPrompt ?? "").toContain("You must call the get_weather tool");
|
|
const json = (await res.json()) as { choices?: Array<{ finish_reason?: string | null }> };
|
|
expect(json.choices?.[0]?.finish_reason).toBe("tool_calls");
|
|
}
|
|
|
|
{
|
|
agentCommand.mockClear();
|
|
agentCommand.mockResolvedValueOnce({
|
|
payloads: [{ text: "tool choice required" }],
|
|
meta: {
|
|
stopReason: "tool_calls",
|
|
pendingToolCalls: [
|
|
{
|
|
id: "call_1",
|
|
name: "get_weather",
|
|
arguments: '{"city":"Taipei"}',
|
|
},
|
|
],
|
|
},
|
|
} as never);
|
|
const res = await postChatCompletions(port, {
|
|
model: "openclaw",
|
|
tool_choice: "required",
|
|
tools: [
|
|
{
|
|
type: "function",
|
|
function: {
|
|
name: "get_weather",
|
|
description: "Get current weather",
|
|
parameters: {
|
|
type: "object",
|
|
properties: { city: { type: "string" } },
|
|
required: ["city"],
|
|
},
|
|
},
|
|
},
|
|
],
|
|
messages: [{ role: "user", content: "weather?" }],
|
|
});
|
|
expect(res.status).toBe(200);
|
|
const firstCall = getFirstAgentCall();
|
|
const clientTools = firstCall?.clientTools ?? [];
|
|
expect(clientTools).toHaveLength(1);
|
|
expect(clientTools[0]?.function?.name).toBe("get_weather");
|
|
expect(firstCall?.extraSystemPrompt ?? "").toContain(
|
|
"You must call one of the available tools",
|
|
);
|
|
const json = (await res.json()) as { choices?: Array<{ finish_reason?: string | null }> };
|
|
expect(json.choices?.[0]?.finish_reason).toBe("tool_calls");
|
|
}
|
|
|
|
{
|
|
mockAgentOnce([{ text: "plain text despite required" }]);
|
|
const res = await postChatCompletions(port, {
|
|
model: "openclaw",
|
|
tool_choice: "required",
|
|
tools: [
|
|
{
|
|
type: "function",
|
|
function: {
|
|
name: "get_weather",
|
|
description: "Get current weather",
|
|
parameters: { type: "object", properties: {} },
|
|
},
|
|
},
|
|
],
|
|
messages: [{ role: "user", content: "weather?" }],
|
|
});
|
|
expect(res.status).toBe(502);
|
|
const json = (await res.json()) as { error?: { type?: string; message?: string } };
|
|
expect(json.error?.type).toBe("api_error");
|
|
expect(json.error?.message ?? "").toContain("tool_choice=required was not satisfied");
|
|
}
|
|
|
|
{
|
|
agentCommand.mockClear();
|
|
agentCommand.mockResolvedValueOnce({
|
|
payloads: [{ text: "Calling a different tool." }],
|
|
meta: {
|
|
stopReason: "tool_calls",
|
|
pendingToolCalls: [{ id: "call_1", name: "get_time", arguments: "{}" }],
|
|
},
|
|
} as never);
|
|
const res = await postChatCompletions(port, {
|
|
model: "openclaw",
|
|
tool_choice: { type: "function", function: { name: "get_weather" } },
|
|
tools: [
|
|
{
|
|
type: "function",
|
|
function: {
|
|
name: "get_weather",
|
|
description: "Get current weather",
|
|
parameters: { type: "object", properties: {} },
|
|
},
|
|
},
|
|
{
|
|
type: "function",
|
|
function: {
|
|
name: "get_time",
|
|
description: "Get current time",
|
|
parameters: { type: "object", properties: {} },
|
|
},
|
|
},
|
|
],
|
|
messages: [{ role: "user", content: "weather?" }],
|
|
});
|
|
expect(res.status).toBe(502);
|
|
const json = (await res.json()) as { error?: { type?: string; message?: string } };
|
|
expect(json.error?.type).toBe("api_error");
|
|
expect(json.error?.message ?? "").toContain("tool_choice required a get_weather tool call");
|
|
}
|
|
|
|
{
|
|
agentCommand.mockClear();
|
|
const res = await postChatCompletions(port, {
|
|
model: "openclaw",
|
|
tool_choice: "required",
|
|
messages: [{ role: "user", content: "weather?" }],
|
|
});
|
|
expect(res.status).toBe(400);
|
|
const json = (await res.json()) as { error?: { type?: string; message?: string } };
|
|
expect(json.error?.type).toBe("invalid_request_error");
|
|
expect(json.error?.message ?? "").toContain("no tools were provided");
|
|
expect(agentCommand).toHaveBeenCalledTimes(0);
|
|
}
|
|
|
|
{
|
|
agentCommand.mockClear();
|
|
const res = await postChatCompletions(port, {
|
|
model: "openclaw",
|
|
tool_choice: { type: "function", function: { name: "missing_tool" } },
|
|
tools: [
|
|
{
|
|
type: "function",
|
|
function: {
|
|
name: "get_time",
|
|
description: "Get current time",
|
|
parameters: { type: "object", properties: {} },
|
|
},
|
|
},
|
|
],
|
|
messages: [{ role: "user", content: "weather?" }],
|
|
});
|
|
expect(res.status).toBe(400);
|
|
const json = (await res.json()) as { error?: { type?: string; message?: string } };
|
|
expect(json.error?.type).toBe("invalid_request_error");
|
|
expect(json.error?.message ?? "").toContain("unknown tool");
|
|
expect(agentCommand).toHaveBeenCalledTimes(0);
|
|
}
|
|
|
|
{
|
|
agentCommand.mockClear();
|
|
const res = await postChatCompletions(port, {
|
|
model: "openclaw",
|
|
tool_choice: {
|
|
type: "allowed_tools",
|
|
tools: [{ type: "function", function: { name: "x" } }],
|
|
},
|
|
tools: [
|
|
{
|
|
type: "function",
|
|
function: {
|
|
name: "x",
|
|
description: "x",
|
|
parameters: { type: "object", properties: {} },
|
|
},
|
|
},
|
|
],
|
|
messages: [{ role: "user", content: "x?" }],
|
|
});
|
|
expect(res.status).toBe(400);
|
|
const json = (await res.json()) as { error?: { type?: string; message?: string } };
|
|
expect(json.error?.type).toBe("invalid_request_error");
|
|
expect(json.error?.message ?? "").toContain("allowed_tools");
|
|
expect(agentCommand).toHaveBeenCalledTimes(0);
|
|
}
|
|
|
|
{
|
|
agentCommand.mockClear();
|
|
const res = await postChatCompletions(port, {
|
|
model: "openclaw",
|
|
tools: [
|
|
{
|
|
type: "function",
|
|
name: "invalid_flat_shape",
|
|
parameters: { type: "object", properties: {} },
|
|
},
|
|
],
|
|
messages: [{ role: "user", content: "x?" }],
|
|
});
|
|
expect(res.status).toBe(400);
|
|
const json = (await res.json()) as { error?: { type?: string; message?: string } };
|
|
expect(json.error?.type).toBe("invalid_request_error");
|
|
expect(json.error?.message ?? "").toContain("tool.function is required");
|
|
expect(agentCommand).toHaveBeenCalledTimes(0);
|
|
}
|
|
|
|
{
|
|
mockAgentOnce([{ text: "ok" }]);
|
|
const res = await postChatCompletions(port, {
|
|
model: "openclaw",
|
|
messages: [
|
|
{ role: "user", content: "What's the weather?" },
|
|
{ role: "assistant", content: "Checking the weather." },
|
|
{
|
|
role: "tool",
|
|
tool_call_id: "call_1",
|
|
content: [{ type: "text", text: "Sunny, 70F." }],
|
|
},
|
|
],
|
|
});
|
|
expect(res.status).toBe(200);
|
|
const message = getFirstAgentMessage();
|
|
expectMessageContext(message, {
|
|
history: ["User: What's the weather?", "Assistant: Checking the weather."],
|
|
current: ["Tool:call_1: Sunny, 70F."],
|
|
});
|
|
await res.text();
|
|
}
|
|
|
|
{
|
|
mockAgentOnce([{ text: "ok" }]);
|
|
const res = await postChatCompletions(port, {
|
|
model: "openclaw",
|
|
messages: [
|
|
{ role: "user", content: "What's the weather?" },
|
|
{
|
|
role: "assistant",
|
|
content: null,
|
|
tool_calls: [
|
|
{
|
|
id: "call_1",
|
|
type: "function",
|
|
function: {
|
|
name: "get_weather",
|
|
arguments: '{"city":"Taipei"}',
|
|
},
|
|
},
|
|
],
|
|
},
|
|
{
|
|
role: "tool",
|
|
tool_call_id: "call_1",
|
|
content: [{ type: "text", text: "Sunny, 70F." }],
|
|
},
|
|
],
|
|
});
|
|
expect(res.status).toBe(200);
|
|
const message = getFirstAgentMessage();
|
|
expectMessageContext(message, {
|
|
history: [
|
|
"User: What's the weather?",
|
|
'Assistant: tool_call id=call_1 name=get_weather arguments={"city":"Taipei"}',
|
|
],
|
|
current: ["Tool:call_1: Sunny, 70F."],
|
|
});
|
|
await res.text();
|
|
}
|
|
|
|
{
|
|
agentCommand.mockClear();
|
|
agentCommand.mockRejectedValueOnce(createClientToolNameConflictError(["exec"]));
|
|
const res = await postChatCompletions(port, {
|
|
stream: false,
|
|
model: "openclaw",
|
|
tools: [
|
|
{
|
|
type: "function",
|
|
function: {
|
|
name: "exec",
|
|
description: "conflicts with a built-in tool",
|
|
parameters: { type: "object", properties: {} },
|
|
},
|
|
},
|
|
],
|
|
messages: [{ role: "user", content: "run command" }],
|
|
});
|
|
expect(res.status).toBe(400);
|
|
const json = (await res.json()) as { error?: { type?: string; message?: string } };
|
|
expect(json.error?.type).toBe("invalid_request_error");
|
|
expect(json.error?.message).toBe("invalid tool configuration");
|
|
}
|
|
|
|
{
|
|
agentCommand.mockClear();
|
|
agentCommand.mockResolvedValueOnce({
|
|
payloads: [{ text: "Let me check that." }],
|
|
meta: {
|
|
stopReason: "tool_calls",
|
|
pendingToolCalls: [
|
|
{
|
|
id: "call_1",
|
|
name: "get_weather",
|
|
arguments: '{"city":"Taipei"}',
|
|
},
|
|
{
|
|
id: "call_2",
|
|
name: "get_time",
|
|
arguments: "{}",
|
|
},
|
|
],
|
|
agentMeta: {
|
|
usage: {
|
|
input: 10,
|
|
output: 5,
|
|
total: 15,
|
|
},
|
|
},
|
|
},
|
|
} as never);
|
|
const res = await postChatCompletions(port, {
|
|
stream: false,
|
|
model: "openclaw",
|
|
tool_choice: "auto",
|
|
tools: [
|
|
{
|
|
type: "function",
|
|
function: {
|
|
name: "get_weather",
|
|
description: "Get weather",
|
|
parameters: { type: "object", properties: { city: { type: "string" } } },
|
|
},
|
|
},
|
|
{
|
|
type: "function",
|
|
function: {
|
|
name: "get_time",
|
|
description: "Get time",
|
|
parameters: { type: "object", properties: {} },
|
|
},
|
|
},
|
|
],
|
|
messages: [{ role: "user", content: "weather?" }],
|
|
});
|
|
expect(res.status).toBe(200);
|
|
const json = (await res.json()) as {
|
|
choices?: Array<{
|
|
finish_reason?: string | null;
|
|
message?: {
|
|
role?: string;
|
|
content?: string;
|
|
tool_calls?: Array<{
|
|
index?: number;
|
|
id?: string;
|
|
type?: string;
|
|
function?: { name?: string; arguments?: string };
|
|
}>;
|
|
};
|
|
}>;
|
|
};
|
|
const choice = json.choices?.[0];
|
|
expect(choice?.finish_reason).toBe("tool_calls");
|
|
expect(choice?.message?.role).toBe("assistant");
|
|
expect(choice?.message?.content).toBe("Let me check that.");
|
|
expect(choice?.message?.tool_calls).toEqual([
|
|
{
|
|
id: "call_1",
|
|
type: "function",
|
|
function: { name: "get_weather", arguments: '{"city":"Taipei"}' },
|
|
},
|
|
{
|
|
id: "call_2",
|
|
type: "function",
|
|
function: { name: "get_time", arguments: "{}" },
|
|
},
|
|
]);
|
|
expect(choice?.message?.tool_calls?.some((call) => Object.hasOwn(call, "index"))).toBe(
|
|
false,
|
|
);
|
|
}
|
|
|
|
{
|
|
agentCommand.mockClear();
|
|
agentCommand.mockResolvedValueOnce({
|
|
payloads: [],
|
|
meta: {
|
|
stopReason: "tool_calls",
|
|
pendingToolCalls: [
|
|
{
|
|
id: "call_1",
|
|
name: "get_weather",
|
|
arguments: '{"city":"Taipei"}',
|
|
},
|
|
],
|
|
},
|
|
} as never);
|
|
const res = await postChatCompletions(port, {
|
|
stream: false,
|
|
model: "openclaw",
|
|
tool_choice: "auto",
|
|
tools: [
|
|
{
|
|
type: "function",
|
|
function: {
|
|
name: "get_weather",
|
|
description: "Get weather",
|
|
parameters: { type: "object", properties: { city: { type: "string" } } },
|
|
},
|
|
},
|
|
],
|
|
messages: [{ role: "user", content: "weather?" }],
|
|
});
|
|
expect(res.status).toBe(200);
|
|
const json = (await res.json()) as {
|
|
choices?: Array<{
|
|
finish_reason?: string | null;
|
|
message?: { content?: string; tool_calls?: unknown[] };
|
|
}>;
|
|
};
|
|
const choice = json.choices?.[0];
|
|
expect(choice?.finish_reason).toBe("tool_calls");
|
|
expect(choice?.message?.content).toBe("");
|
|
expect(choice?.message?.tool_calls).toHaveLength(1);
|
|
}
|
|
|
|
{
|
|
mockAgentOnce([{ text: "hello" }]);
|
|
const json = await postSyncUserMessage("hi");
|
|
expect(json.object).toBe("chat.completion");
|
|
expect(Array.isArray(json.choices)).toBe(true);
|
|
const choice0 = (json.choices as Array<Record<string, unknown>>)[0] ?? {};
|
|
const msg = (choice0.message as Record<string, unknown> | undefined) ?? {};
|
|
expect(msg.role).toBe("assistant");
|
|
expect(msg.content).toBe("hello");
|
|
}
|
|
|
|
{
|
|
agentCommand.mockClear();
|
|
agentCommand.mockImplementationOnce((async (opts: unknown) => {
|
|
const runId = (opts as { runId?: string } | undefined)?.runId ?? "";
|
|
const { session, emit } = createStubSessionHarness();
|
|
subscribeEmbeddedAgentSession({ session, runId });
|
|
emit({ type: "message_start", message: { role: "assistant" } });
|
|
for (const delta of ["<", "final>Title\n", "Line one\nLine two</", "final>"]) {
|
|
emitAssistantTextDelta({ emit, delta });
|
|
}
|
|
return { payloads: [{ text: "Title\nLine one\nLine two" }] };
|
|
}) as never);
|
|
|
|
const splitFinalRes = await postChatCompletions(port, {
|
|
stream: true,
|
|
model: "openclaw",
|
|
messages: [{ role: "user", content: "hi" }],
|
|
});
|
|
expect(splitFinalRes.status).toBe(200);
|
|
const splitFinalText = await splitFinalRes.text();
|
|
const splitFinalData = parseSseDataLines(splitFinalText);
|
|
const splitFinalChunks = splitFinalData
|
|
.filter((d) => d !== "[DONE]")
|
|
.map((d) => JSON.parse(d) as Record<string, unknown>);
|
|
const splitFinalContent = splitFinalChunks
|
|
.flatMap((c) => (c.choices as Array<Record<string, unknown>> | undefined) ?? [])
|
|
.map((choice) => (choice.delta as Record<string, unknown> | undefined)?.content)
|
|
.filter((v): v is string => typeof v === "string")
|
|
.join("");
|
|
expect(splitFinalContent).toBe("Title\nLine one\nLine two");
|
|
expect(splitFinalContent).not.toContain("<");
|
|
expect(splitFinalContent).not.toContain("final>");
|
|
}
|
|
|
|
{
|
|
agentCommand.mockClear();
|
|
agentCommand.mockResolvedValueOnce({
|
|
payloads: [{ text: "usage basic" }],
|
|
meta: {
|
|
agentMeta: {
|
|
usage: {
|
|
input: 42,
|
|
output: 17,
|
|
},
|
|
},
|
|
},
|
|
} as never);
|
|
const json = await postSyncUserMessage("usage");
|
|
expect(json.usage).toEqual({
|
|
prompt_tokens: 42,
|
|
completion_tokens: 17,
|
|
total_tokens: 59,
|
|
});
|
|
}
|
|
|
|
{
|
|
agentCommand.mockClear();
|
|
agentCommand.mockResolvedValueOnce({
|
|
payloads: [{ text: "usage cache" }],
|
|
meta: {
|
|
agentMeta: {
|
|
usage: {
|
|
input: 10,
|
|
output: 5,
|
|
cacheRead: 20,
|
|
cacheWrite: 3,
|
|
},
|
|
},
|
|
},
|
|
} as never);
|
|
const json = await postSyncUserMessage("usage");
|
|
expect(json.usage).toEqual({
|
|
prompt_tokens: 30,
|
|
completion_tokens: 5,
|
|
total_tokens: 35,
|
|
prompt_tokens_details: { cached_tokens: 20 },
|
|
});
|
|
}
|
|
|
|
{
|
|
agentCommand.mockClear();
|
|
agentCommand.mockResolvedValueOnce({
|
|
payloads: [{ text: "usage total" }],
|
|
meta: {
|
|
agentMeta: {
|
|
usage: {
|
|
input: 10,
|
|
output: 5,
|
|
total: 100,
|
|
},
|
|
},
|
|
},
|
|
} as never);
|
|
const json = await postSyncUserMessage("usage");
|
|
expect(json.usage).toEqual({
|
|
prompt_tokens: 10,
|
|
completion_tokens: 5,
|
|
total_tokens: 100,
|
|
});
|
|
}
|
|
|
|
{
|
|
agentCommand.mockClear();
|
|
agentCommand.mockResolvedValueOnce({
|
|
payloads: [{ text: "usage total only" }],
|
|
meta: {
|
|
agentMeta: {
|
|
usage: {
|
|
total: 123,
|
|
},
|
|
},
|
|
},
|
|
} as never);
|
|
const json = await postSyncUserMessage("usage");
|
|
expect(json.usage).toEqual({
|
|
prompt_tokens: 0,
|
|
completion_tokens: 0,
|
|
total_tokens: 123,
|
|
});
|
|
}
|
|
|
|
{
|
|
agentCommand.mockClear();
|
|
agentCommand.mockResolvedValueOnce({
|
|
payloads: [{ text: "usage non-finite" }],
|
|
meta: {
|
|
agentMeta: {
|
|
usage: {
|
|
input: Number.POSITIVE_INFINITY,
|
|
output: Number.NaN,
|
|
cacheRead: 2,
|
|
cacheWrite: Number.POSITIVE_INFINITY,
|
|
total: Number.NaN,
|
|
},
|
|
},
|
|
},
|
|
} as never);
|
|
const json = await postSyncUserMessage("usage");
|
|
expect(json.usage).toEqual({
|
|
prompt_tokens: 2,
|
|
completion_tokens: 0,
|
|
total_tokens: 2,
|
|
prompt_tokens_details: { cached_tokens: 2 },
|
|
});
|
|
}
|
|
|
|
{
|
|
agentCommand.mockClear();
|
|
agentCommand.mockResolvedValueOnce({
|
|
payloads: [{ text: "usage non-finite aggregate fallback" }],
|
|
meta: {
|
|
agentMeta: {
|
|
usage: {
|
|
input: Number.POSITIVE_INFINITY,
|
|
output: Number.NaN,
|
|
total: 123,
|
|
},
|
|
},
|
|
},
|
|
} as never);
|
|
const json = await postSyncUserMessage("usage");
|
|
expect(json.usage).toEqual({
|
|
prompt_tokens: 0,
|
|
completion_tokens: 0,
|
|
total_tokens: 123,
|
|
});
|
|
}
|
|
|
|
{
|
|
agentCommand.mockClear();
|
|
agentCommand.mockResolvedValueOnce({
|
|
payloads: [{ text: "usage cache-write only" }],
|
|
meta: {
|
|
agentMeta: {
|
|
usage: {
|
|
cacheWrite: 10,
|
|
total: 10,
|
|
},
|
|
},
|
|
},
|
|
} as never);
|
|
const json = await postSyncUserMessage("usage");
|
|
expect(json.usage).toEqual({
|
|
prompt_tokens: 0,
|
|
completion_tokens: 0,
|
|
total_tokens: 10,
|
|
});
|
|
}
|
|
|
|
{
|
|
agentCommand.mockClear();
|
|
agentCommand.mockResolvedValueOnce({ payloads: [{ text: "" }] } as never);
|
|
const json = await postSyncUserMessage("hi");
|
|
const choice0 = (json.choices as Array<Record<string, unknown>>)[0] ?? {};
|
|
const msg = (choice0.message as Record<string, unknown> | undefined) ?? {};
|
|
expect(msg.content).toBe("No response from OpenClaw.");
|
|
}
|
|
|
|
{
|
|
const res = await postChatCompletions(port, {
|
|
model: "openclaw",
|
|
messages: [{ role: "system", content: "yo" }],
|
|
});
|
|
expect(res.status).toBe(400);
|
|
const missingUserJson = (await res.json()) as Record<string, unknown>;
|
|
expect((missingUserJson.error as Record<string, unknown> | undefined)?.type).toBe(
|
|
"invalid_request_error",
|
|
);
|
|
}
|
|
} finally {
|
|
// shared server
|
|
}
|
|
});
|
|
|
|
it("forwards inbound max_completion_tokens and max_tokens into streamParams", async () => {
|
|
const port = enabledPort;
|
|
const mockAgentOnce = (payloads: Array<{ text: string }>) => {
|
|
agentCommand.mockClear();
|
|
agentCommand.mockResolvedValueOnce({ payloads } as never);
|
|
};
|
|
const getFirstAgentMaxTokens = () => firstAgentCommandOptions()?.streamParams?.maxTokens;
|
|
|
|
{
|
|
mockAgentOnce([{ text: "hello" }]);
|
|
const res = await postChatCompletions(port, {
|
|
model: "openclaw",
|
|
max_completion_tokens: 256,
|
|
messages: [{ role: "user", content: "hi" }],
|
|
});
|
|
expect(res.status).toBe(200);
|
|
expect(getFirstAgentMaxTokens()).toBe(256);
|
|
await res.text();
|
|
}
|
|
|
|
{
|
|
mockAgentOnce([{ text: "hello" }]);
|
|
const res = await postChatCompletions(port, {
|
|
model: "openclaw",
|
|
max_tokens: 128,
|
|
messages: [{ role: "user", content: "hi" }],
|
|
});
|
|
expect(res.status).toBe(200);
|
|
expect(getFirstAgentMaxTokens()).toBe(128);
|
|
await res.text();
|
|
}
|
|
|
|
{
|
|
mockAgentOnce([{ text: "hello" }]);
|
|
const res = await postChatCompletions(port, {
|
|
model: "openclaw",
|
|
max_completion_tokens: 64,
|
|
max_tokens: 999,
|
|
messages: [{ role: "user", content: "hi" }],
|
|
});
|
|
expect(res.status).toBe(200);
|
|
expect(getFirstAgentMaxTokens()).toBe(64);
|
|
await res.text();
|
|
}
|
|
|
|
{
|
|
mockAgentOnce([{ text: "hello" }]);
|
|
const res = await postChatCompletions(port, {
|
|
model: "openclaw",
|
|
messages: [{ role: "user", content: "hi" }],
|
|
});
|
|
expect(res.status).toBe(200);
|
|
expect(getFirstAgentMaxTokens()).toBeUndefined();
|
|
await res.text();
|
|
}
|
|
});
|
|
|
|
it("forwards inbound temperature and top_p into streamParams", async () => {
|
|
const port = enabledPort;
|
|
const mockAgentOnce = (payloads: Array<{ text: string }>) => {
|
|
agentCommand.mockClear();
|
|
agentCommand.mockResolvedValueOnce({ payloads } as never);
|
|
};
|
|
const getStreamParams = () => firstAgentCommandOptions()?.streamParams;
|
|
|
|
{
|
|
mockAgentOnce([{ text: "hello" }]);
|
|
const res = await postChatCompletions(port, {
|
|
model: "openclaw",
|
|
temperature: 0.3,
|
|
top_p: 0.95,
|
|
messages: [{ role: "user", content: "hi" }],
|
|
});
|
|
expect(res.status).toBe(200);
|
|
expect(getStreamParams()).toMatchObject({ temperature: 0.3, topP: 0.95 });
|
|
await res.text();
|
|
}
|
|
|
|
{
|
|
mockAgentOnce([{ text: "hello" }]);
|
|
const res = await postChatCompletions(port, {
|
|
model: "openclaw",
|
|
temperature: 0,
|
|
messages: [{ role: "user", content: "hi" }],
|
|
});
|
|
expect(res.status).toBe(200);
|
|
const params = getStreamParams();
|
|
expect(params?.temperature).toBe(0);
|
|
expect(params?.topP).toBeUndefined();
|
|
await res.text();
|
|
}
|
|
|
|
{
|
|
mockAgentOnce([{ text: "hello" }]);
|
|
const res = await postChatCompletions(port, {
|
|
model: "openclaw",
|
|
messages: [{ role: "user", content: "hi" }],
|
|
});
|
|
expect(res.status).toBe(200);
|
|
expect(getStreamParams()).toBeUndefined();
|
|
await res.text();
|
|
}
|
|
|
|
{
|
|
agentCommand.mockClear();
|
|
const res = await postChatCompletions(port, {
|
|
model: "openclaw",
|
|
temperature: 999,
|
|
messages: [{ role: "user", content: "hi" }],
|
|
});
|
|
expect(res.status).toBe(400);
|
|
const json = (await res.json()) as { error?: { type?: string; message?: string } };
|
|
expect(json.error?.type).toBe("invalid_request_error");
|
|
expect(json.error?.message).toMatch(/temperature/);
|
|
expect(agentCommand).toHaveBeenCalledTimes(0);
|
|
}
|
|
|
|
{
|
|
agentCommand.mockClear();
|
|
const res = await postChatCompletions(port, {
|
|
model: "openclaw",
|
|
top_p: 5,
|
|
messages: [{ role: "user", content: "hi" }],
|
|
});
|
|
expect(res.status).toBe(400);
|
|
const json = (await res.json()) as { error?: { type?: string; message?: string } };
|
|
expect(json.error?.type).toBe("invalid_request_error");
|
|
expect(json.error?.message).toMatch(/top_p/);
|
|
expect(agentCommand).toHaveBeenCalledTimes(0);
|
|
}
|
|
});
|
|
|
|
it("forwards inbound penalty and seed params into streamParams", async () => {
|
|
const port = enabledPort;
|
|
const mockAgentOnce = (payloads: Array<{ text: string }>) => {
|
|
agentCommand.mockClear();
|
|
agentCommand.mockResolvedValueOnce({ payloads } as never);
|
|
};
|
|
const getStreamParams = () => firstAgentCommandOptions()?.streamParams;
|
|
|
|
{
|
|
mockAgentOnce([{ text: "hello" }]);
|
|
const res = await postChatCompletions(port, {
|
|
model: "openclaw",
|
|
frequency_penalty: -0.5,
|
|
presence_penalty: 1.25,
|
|
seed: 12345,
|
|
messages: [{ role: "user", content: "hi" }],
|
|
});
|
|
expect(res.status).toBe(200);
|
|
expect(getStreamParams()).toMatchObject({
|
|
frequencyPenalty: -0.5,
|
|
presencePenalty: 1.25,
|
|
seed: 12345,
|
|
});
|
|
await res.text();
|
|
}
|
|
|
|
for (const body of [{ frequency_penalty: 3 }, { presence_penalty: -3 }, { seed: 1.5 }]) {
|
|
agentCommand.mockClear();
|
|
const res = await postChatCompletions(port, {
|
|
model: "openclaw",
|
|
...body,
|
|
messages: [{ role: "user", content: "hi" }],
|
|
});
|
|
expect(res.status).toBe(400);
|
|
const json = (await res.json()) as { error?: { type?: string; message?: string } };
|
|
expect(json.error?.type).toBe("invalid_request_error");
|
|
expect(agentCommand).toHaveBeenCalledTimes(0);
|
|
}
|
|
});
|
|
|
|
it("forwards inbound stop into streamParams", async () => {
|
|
const port = enabledPort;
|
|
const mockAgentOnce = (payloads: Array<{ text: string }>) => {
|
|
agentCommand.mockClear();
|
|
agentCommand.mockResolvedValueOnce({ payloads } as never);
|
|
};
|
|
const getStreamParams = () => firstAgentCommandOptions()?.streamParams;
|
|
|
|
{
|
|
mockAgentOnce([{ text: "hello" }]);
|
|
const res = await postChatCompletions(port, {
|
|
model: "openclaw",
|
|
stop: "\n\n",
|
|
messages: [{ role: "user", content: "hi" }],
|
|
});
|
|
expect(res.status).toBe(200);
|
|
expect(getStreamParams()).toMatchObject({ stop: ["\n\n"] });
|
|
await res.text();
|
|
}
|
|
|
|
{
|
|
mockAgentOnce([{ text: "hello" }]);
|
|
const res = await postChatCompletions(port, {
|
|
model: "openclaw",
|
|
stop: ["User:", "Assistant:"],
|
|
messages: [{ role: "user", content: "hi" }],
|
|
});
|
|
expect(res.status).toBe(200);
|
|
expect(getStreamParams()).toMatchObject({ stop: ["User:", "Assistant:"] });
|
|
await res.text();
|
|
}
|
|
|
|
{
|
|
mockAgentOnce([{ text: "hello" }]);
|
|
const res = await postChatCompletions(port, {
|
|
model: "openclaw",
|
|
messages: [{ role: "user", content: "hi" }],
|
|
});
|
|
expect(res.status).toBe(200);
|
|
expect(getStreamParams()).toBeUndefined();
|
|
await res.text();
|
|
}
|
|
|
|
for (const stop of [["a", "b", "c", "d", "e"], [""], [123], {}]) {
|
|
agentCommand.mockClear();
|
|
const res = await postChatCompletions(port, {
|
|
model: "openclaw",
|
|
stop,
|
|
messages: [{ role: "user", content: "hi" }],
|
|
});
|
|
expect(res.status).toBe(400);
|
|
const json = (await res.json()) as { error?: { type?: string; message?: string } };
|
|
expect(json.error?.type).toBe("invalid_request_error");
|
|
expect(json.error?.message).toMatch(/stop/);
|
|
expect(agentCommand).toHaveBeenCalledTimes(0);
|
|
}
|
|
});
|
|
|
|
it("maps provider format failures to OpenAI-compatible 400 errors", async () => {
|
|
const port = enabledPort;
|
|
|
|
agentCommand.mockClear();
|
|
agentCommand.mockRejectedValueOnce(
|
|
new FailoverError(
|
|
"LLM request failed: provider rejected the request schema or tool payload.",
|
|
{
|
|
reason: "format",
|
|
status: 400,
|
|
code: "decimal_above_max_value",
|
|
rawError:
|
|
"400 Invalid 'temperature': decimal above maximum value. Expected a value <= 2, but got 999 instead.",
|
|
},
|
|
) as never,
|
|
);
|
|
|
|
const res = await postChatCompletions(port, {
|
|
model: "openclaw",
|
|
messages: [{ role: "user", content: "hi" }],
|
|
});
|
|
expect(res.status).toBe(400);
|
|
const json = (await res.json()) as {
|
|
error?: { type?: string; code?: string; message?: string };
|
|
};
|
|
expect(json.error?.type).toBe("invalid_request_error");
|
|
expect(json.error?.code).toBe("decimal_above_max_value");
|
|
expect(json.error?.message).toContain("Invalid 'temperature'");
|
|
expect(agentCommand).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("forwards response_format into streamParams", async () => {
|
|
const port = enabledPort;
|
|
const mockAgentOnce = (payloads: Array<{ text: string }>) => {
|
|
agentCommand.mockClear();
|
|
agentCommand.mockResolvedValueOnce({ payloads } as never);
|
|
};
|
|
const getStreamParams = () => firstAgentCommandOptions()?.streamParams;
|
|
|
|
{
|
|
mockAgentOnce([{ text: "{}" }]);
|
|
const res = await postChatCompletions(port, {
|
|
model: "openclaw",
|
|
response_format: { type: "json_object" },
|
|
messages: [{ role: "user", content: "hi" }],
|
|
});
|
|
expect(res.status).toBe(200);
|
|
expect(getStreamParams()).toMatchObject({ responseFormat: { type: "json_object" } });
|
|
await res.text();
|
|
}
|
|
|
|
{
|
|
mockAgentOnce([{ text: "{}" }]);
|
|
const res = await postChatCompletions(port, {
|
|
model: "openclaw",
|
|
response_format: {
|
|
type: "json_schema",
|
|
json_schema: { name: "test", schema: { type: "object" } },
|
|
},
|
|
messages: [{ role: "user", content: "hi" }],
|
|
});
|
|
expect(res.status).toBe(200);
|
|
expect(getStreamParams()).toMatchObject({
|
|
responseFormat: { type: "json_schema" },
|
|
});
|
|
await res.text();
|
|
}
|
|
|
|
{
|
|
mockAgentOnce([{ text: "hello" }]);
|
|
const res = await postChatCompletions(port, {
|
|
model: "openclaw",
|
|
response_format: { type: "text" },
|
|
messages: [{ role: "user", content: "hi" }],
|
|
});
|
|
expect(res.status).toBe(200);
|
|
expect(getStreamParams()).toMatchObject({ responseFormat: { type: "text" } });
|
|
await res.text();
|
|
}
|
|
|
|
{
|
|
mockAgentOnce([{ text: "hello" }]);
|
|
const res = await postChatCompletions(port, {
|
|
model: "openclaw",
|
|
messages: [{ role: "user", content: "hi" }],
|
|
});
|
|
expect(res.status).toBe(200);
|
|
expect(getStreamParams()).toBeUndefined();
|
|
await res.text();
|
|
}
|
|
|
|
{
|
|
agentCommand.mockClear();
|
|
const res = await postChatCompletions(port, {
|
|
model: "openclaw",
|
|
response_format: { type: "xml" },
|
|
messages: [{ role: "user", content: "hi" }],
|
|
});
|
|
expect(res.status).toBe(400);
|
|
const json = (await res.json()) as { error?: { type?: string; message?: string } };
|
|
expect(json.error?.type).toBe("invalid_request_error");
|
|
expect(json.error?.message).toMatch(/response_format/);
|
|
expect(agentCommand).toHaveBeenCalledTimes(0);
|
|
}
|
|
});
|
|
|
|
it("returns 429 for repeated failed auth when gateway.auth.rateLimit is configured", async () => {
|
|
testState.gatewayAuth = {
|
|
mode: "token",
|
|
token: "secret",
|
|
rateLimit: { maxAttempts: 1, windowMs: 60_000, lockoutMs: 60_000, exemptLoopback: false },
|
|
} as any;
|
|
await withGatewayServer(
|
|
async ({ port }) => {
|
|
const headers = {
|
|
"content-type": "application/json",
|
|
authorization: "Bearer wrong",
|
|
};
|
|
const body = {
|
|
model: "openclaw",
|
|
messages: [{ role: "user", content: "hi" }],
|
|
};
|
|
|
|
const first = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, {
|
|
method: "POST",
|
|
headers,
|
|
body: JSON.stringify(body),
|
|
});
|
|
expect(first.status).toBe(401);
|
|
|
|
const second = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, {
|
|
method: "POST",
|
|
headers,
|
|
body: JSON.stringify(body),
|
|
});
|
|
expect(second.status).toBe(429);
|
|
expect(second.headers.get("retry-after")).toMatch(/^\d+$/);
|
|
},
|
|
{
|
|
serverOptions: {
|
|
host: "127.0.0.1",
|
|
controlUiEnabled: false,
|
|
openAiChatCompletionsEnabled: true,
|
|
},
|
|
},
|
|
);
|
|
});
|
|
|
|
it("streams SSE chunks when stream=true", async () => {
|
|
const port = enabledPort;
|
|
try {
|
|
{
|
|
agentCommand.mockClear();
|
|
agentCommand.mockImplementationOnce((async (opts: unknown) =>
|
|
buildAssistantDeltaResult({
|
|
opts,
|
|
emit: emitAgentEvent,
|
|
deltas: ["he", "llo"],
|
|
text: "hello",
|
|
})) as never);
|
|
|
|
const res = await postChatCompletions(port, {
|
|
stream: true,
|
|
model: "openclaw",
|
|
messages: [{ role: "user", content: "hi" }],
|
|
});
|
|
expect(res.status).toBe(200);
|
|
expect(res.headers.get("content-type") ?? "").toContain("text/event-stream");
|
|
|
|
const text = await res.text();
|
|
const data = parseSseDataLines(text);
|
|
expect(data[data.length - 1]).toBe("[DONE]");
|
|
|
|
const jsonChunks = data
|
|
.filter((d) => d !== "[DONE]")
|
|
.map((d) => JSON.parse(d) as Record<string, unknown>);
|
|
expect(jsonChunks.map((chunk) => chunk.object)).toContain("chat.completion.chunk");
|
|
const allContent = jsonChunks
|
|
.flatMap((c) => (c.choices as Array<Record<string, unknown>> | undefined) ?? [])
|
|
.map((choice) => (choice.delta as Record<string, unknown> | undefined)?.content)
|
|
.filter((v): v is string => typeof v === "string")
|
|
.join("");
|
|
expect(allContent).toBe("hello");
|
|
const usageChunks = jsonChunks.filter((c) => "usage" in c);
|
|
expect(usageChunks).toHaveLength(0);
|
|
}
|
|
|
|
{
|
|
agentCommand.mockClear();
|
|
agentCommand.mockImplementationOnce((async (opts: unknown) =>
|
|
buildAssistantDeltaResult({
|
|
opts,
|
|
emit: emitAgentEvent,
|
|
deltas: ["hi", "hi"],
|
|
text: "hihi",
|
|
})) as never);
|
|
|
|
const repeatedRes = await postChatCompletions(port, {
|
|
stream: true,
|
|
model: "openclaw",
|
|
messages: [{ role: "user", content: "hi" }],
|
|
});
|
|
expect(repeatedRes.status).toBe(200);
|
|
const repeatedText = await repeatedRes.text();
|
|
const repeatedData = parseSseDataLines(repeatedText);
|
|
const repeatedChunks = repeatedData
|
|
.filter((d) => d !== "[DONE]")
|
|
.map((d) => JSON.parse(d) as Record<string, unknown>);
|
|
const repeatedContent = repeatedChunks
|
|
.flatMap((c) => (c.choices as Array<Record<string, unknown>> | undefined) ?? [])
|
|
.map((choice) => (choice.delta as Record<string, unknown> | undefined)?.content)
|
|
.filter((v): v is string => typeof v === "string")
|
|
.join("");
|
|
expect(repeatedContent).toBe("hihi");
|
|
}
|
|
|
|
{
|
|
agentCommand.mockClear();
|
|
agentCommand.mockResolvedValueOnce({
|
|
payloads: [{ text: "hello" }],
|
|
} as never);
|
|
|
|
const fallbackRes = await postChatCompletions(port, {
|
|
stream: true,
|
|
model: "openclaw",
|
|
messages: [{ role: "user", content: "hi" }],
|
|
});
|
|
expect(fallbackRes.status).toBe(200);
|
|
const fallbackText = await fallbackRes.text();
|
|
expect(fallbackText).toContain("[DONE]");
|
|
expect(fallbackText).toContain("hello");
|
|
}
|
|
|
|
{
|
|
agentCommand.mockClear();
|
|
agentCommand.mockImplementationOnce((async (opts: unknown) =>
|
|
buildAssistantDeltaResult({
|
|
opts,
|
|
emit: emitAgentEvent,
|
|
deltas: ["plain text despite required"],
|
|
text: "plain text despite required",
|
|
})) as never);
|
|
|
|
const requiredFailureRes = await postChatCompletions(port, {
|
|
stream: true,
|
|
model: "openclaw",
|
|
tool_choice: "required",
|
|
tools: [
|
|
{
|
|
type: "function",
|
|
function: {
|
|
name: "get_weather",
|
|
description: "Get weather",
|
|
parameters: { type: "object", properties: {} },
|
|
},
|
|
},
|
|
],
|
|
messages: [{ role: "user", content: "weather?" }],
|
|
});
|
|
expect(requiredFailureRes.status).toBe(200);
|
|
const requiredFailureText = await requiredFailureRes.text();
|
|
expect(requiredFailureText).toContain("[DONE]");
|
|
expect(requiredFailureText).toContain("tool_choice=required was not satisfied");
|
|
expect(requiredFailureText).not.toContain("plain text despite required");
|
|
}
|
|
|
|
{
|
|
agentCommand.mockClear();
|
|
agentCommand.mockResolvedValueOnce({
|
|
payloads: [{ text: "Let me check that." }],
|
|
meta: {
|
|
stopReason: "tool_calls",
|
|
pendingToolCalls: [
|
|
{
|
|
id: "call_1",
|
|
name: "get_weather",
|
|
arguments: '{"city":"Taipei"}',
|
|
},
|
|
],
|
|
},
|
|
} as never);
|
|
|
|
const toolCallRes = await postChatCompletions(port, {
|
|
stream: true,
|
|
model: "openclaw",
|
|
messages: [{ role: "user", content: "hi" }],
|
|
});
|
|
expect(toolCallRes.status).toBe(200);
|
|
const toolCallText = await toolCallRes.text();
|
|
const toolCallData = parseSseDataLines(toolCallText);
|
|
const toolCallChunks = toolCallData
|
|
.filter((d) => d !== "[DONE]")
|
|
.map((d) => JSON.parse(d) as Record<string, unknown>);
|
|
const toolDeltaChunks = toolCallChunks.filter((chunk) => {
|
|
const choice = ((chunk.choices as Array<Record<string, unknown>> | undefined) ?? [])[0];
|
|
const delta = (choice?.delta as Record<string, unknown> | undefined) ?? {};
|
|
return Array.isArray(delta.tool_calls);
|
|
});
|
|
expect(toolDeltaChunks.length).toBeGreaterThan(0);
|
|
const toolCallDeltaRecords = toolDeltaChunks.flatMap((chunk) => {
|
|
const choice = ((chunk.choices as Array<Record<string, unknown>> | undefined) ?? [])[0];
|
|
const delta = (choice?.delta as Record<string, unknown> | undefined) ?? {};
|
|
return (delta.tool_calls as Array<Record<string, unknown>> | undefined) ?? [];
|
|
});
|
|
const withIdentity = toolCallDeltaRecords.find(
|
|
(record) =>
|
|
record.id === "call_1" &&
|
|
record.type === "function" &&
|
|
((record.function as Record<string, unknown> | undefined)?.name as
|
|
| string
|
|
| undefined) === "get_weather",
|
|
);
|
|
if (!withIdentity) {
|
|
throw new Error("expected tool call delta with identity");
|
|
}
|
|
const argsJoined = toolCallDeltaRecords
|
|
.filter((record) => record.index === 0)
|
|
.map(
|
|
(record) =>
|
|
((record.function as Record<string, unknown> | undefined)?.arguments as
|
|
| string
|
|
| undefined) ?? "",
|
|
)
|
|
.join("");
|
|
expect(argsJoined).toBe('{"city":"Taipei"}');
|
|
const finishChunk = toolCallChunks
|
|
.flatMap((chunk) => (chunk.choices as Array<Record<string, unknown>> | undefined) ?? [])
|
|
.find((choice) => choice.finish_reason === "tool_calls");
|
|
if (!finishChunk) {
|
|
throw new Error("expected tool_calls finish chunk");
|
|
}
|
|
}
|
|
|
|
{
|
|
agentCommand.mockClear();
|
|
agentCommand.mockResolvedValueOnce({
|
|
payloads: [{ text: "Let me check that." }],
|
|
meta: {
|
|
stopReason: "tool_calls",
|
|
pendingToolCalls: [
|
|
{
|
|
id: "call_1",
|
|
name: "get_weather",
|
|
arguments: '{"city":"Taipei"}',
|
|
},
|
|
],
|
|
agentMeta: {
|
|
usage: {
|
|
input: 12,
|
|
output: 3,
|
|
total: 15,
|
|
},
|
|
},
|
|
},
|
|
} as never);
|
|
|
|
const toolCallUsageRes = await postChatCompletions(port, {
|
|
stream: true,
|
|
stream_options: { include_usage: true },
|
|
model: "openclaw",
|
|
messages: [{ role: "user", content: "hi" }],
|
|
});
|
|
expect(toolCallUsageRes.status).toBe(200);
|
|
const toolCallUsageText = await toolCallUsageRes.text();
|
|
const toolCallUsageData = parseSseDataLines(toolCallUsageText);
|
|
const jsonChunks = toolCallUsageData
|
|
.filter((d) => d !== "[DONE]")
|
|
.map((d) => JSON.parse(d) as Record<string, unknown>);
|
|
const usageChunk = jsonChunks.find((chunk) => "usage" in chunk);
|
|
if (!usageChunk) {
|
|
throw new Error("expected streamed usage chunk");
|
|
}
|
|
expect(usageChunk.choices).toEqual([]);
|
|
expect(usageChunk.usage).toEqual({
|
|
prompt_tokens: 12,
|
|
completion_tokens: 3,
|
|
total_tokens: 15,
|
|
});
|
|
expect(toolCallUsageData[toolCallUsageData.length - 1]).toBe("[DONE]");
|
|
}
|
|
|
|
{
|
|
agentCommand.mockClear();
|
|
let resolveLateToolCall:
|
|
| ((result: {
|
|
payloads: Array<{ text: string }>;
|
|
meta: {
|
|
stopReason: string;
|
|
pendingToolCalls: Array<{ id: string; name: string; arguments: string }>;
|
|
};
|
|
}) => void)
|
|
| undefined;
|
|
agentCommand.mockImplementationOnce(
|
|
((opts: unknown) =>
|
|
new Promise((resolve) => {
|
|
resolveLateToolCall = resolve;
|
|
const runId = (opts as { runId?: string } | undefined)?.runId ?? "";
|
|
emitAgentEvent({ runId, stream: "assistant", data: { delta: "Let me check that." } });
|
|
emitAgentEvent({ runId, stream: "lifecycle", data: { phase: "end" } });
|
|
})) as never,
|
|
);
|
|
|
|
const lateToolCallRes = await postChatCompletions(port, {
|
|
stream: true,
|
|
model: "openclaw",
|
|
messages: [{ role: "user", content: "hi" }],
|
|
});
|
|
expect(lateToolCallRes.status).toBe(200);
|
|
if (!lateToolCallRes.body) {
|
|
throw new Error("expected streaming response body");
|
|
}
|
|
const reader = lateToolCallRes.body.getReader();
|
|
const decoder = new TextDecoder();
|
|
let lateToolCallText = "";
|
|
while (!lateToolCallText.includes("Let me check that.")) {
|
|
const { done, value } = await reader.read();
|
|
if (done) {
|
|
throw new Error("stream ended before early assistant delta");
|
|
}
|
|
lateToolCallText += decoder.decode(value, { stream: true });
|
|
}
|
|
expect(lateToolCallText).not.toContain("[DONE]");
|
|
|
|
resolveLateToolCall?.({
|
|
payloads: [{ text: "Let me check that." }],
|
|
meta: {
|
|
stopReason: "tool_calls",
|
|
pendingToolCalls: [
|
|
{
|
|
id: "call_1",
|
|
name: "get_weather",
|
|
arguments: '{"city":"Taipei"}',
|
|
},
|
|
],
|
|
},
|
|
});
|
|
for (;;) {
|
|
const { done, value } = await reader.read();
|
|
if (done) {
|
|
lateToolCallText += decoder.decode();
|
|
break;
|
|
}
|
|
lateToolCallText += decoder.decode(value, { stream: true });
|
|
}
|
|
const lateToolCallData = parseSseDataLines(lateToolCallText);
|
|
const lateToolCallChunks = lateToolCallData
|
|
.filter((d) => d !== "[DONE]")
|
|
.map((d) => JSON.parse(d) as Record<string, unknown>);
|
|
const finishChunk = lateToolCallChunks
|
|
.flatMap((chunk) => (chunk.choices as Array<Record<string, unknown>> | undefined) ?? [])
|
|
.find((choice) => choice.finish_reason === "tool_calls");
|
|
if (!finishChunk) {
|
|
throw new Error("expected late tool_calls finish chunk");
|
|
}
|
|
const anyToolCalls = lateToolCallChunks.some((chunk) => {
|
|
const choice = ((chunk.choices as Array<Record<string, unknown>> | undefined) ?? [])[0];
|
|
const delta = (choice?.delta as Record<string, unknown> | undefined) ?? {};
|
|
return Array.isArray(delta.tool_calls);
|
|
});
|
|
expect(anyToolCalls).toBe(true);
|
|
}
|
|
|
|
{
|
|
agentCommand.mockClear();
|
|
agentCommand.mockRejectedValueOnce(createClientToolNameConflictError(["exec"]));
|
|
|
|
const toolConflictRes = await postChatCompletions(port, {
|
|
stream: true,
|
|
model: "openclaw",
|
|
tools: [
|
|
{
|
|
type: "function",
|
|
function: {
|
|
name: "exec",
|
|
description: "conflicts with a built-in tool",
|
|
parameters: { type: "object", properties: {} },
|
|
},
|
|
},
|
|
],
|
|
messages: [{ role: "user", content: "run command" }],
|
|
});
|
|
expect(toolConflictRes.status).toBe(200);
|
|
const toolConflictText = await toolConflictRes.text();
|
|
const toolConflictData = parseSseDataLines(toolConflictText);
|
|
expect(toolConflictData[toolConflictData.length - 1]).toBe("[DONE]");
|
|
|
|
const toolConflictChunks = toolConflictData
|
|
.filter((d) => d !== "[DONE]")
|
|
.map((d) => JSON.parse(d) as Record<string, unknown>);
|
|
const protocolError = toolConflictChunks.find(
|
|
(chunk) =>
|
|
typeof chunk.error === "object" &&
|
|
((chunk.error as { type?: unknown }).type ?? "") === "invalid_request_error" &&
|
|
((chunk.error as { message?: unknown }).message ?? "") === "invalid tool configuration",
|
|
);
|
|
if (!protocolError) {
|
|
throw new Error("expected invalid tool configuration protocol error");
|
|
}
|
|
const stopChoice = toolConflictChunks
|
|
.flatMap((c) => (c.choices as Array<Record<string, unknown>> | undefined) ?? [])
|
|
.find((choice) => choice.finish_reason === "stop");
|
|
expect(stopChoice).toBeUndefined();
|
|
}
|
|
|
|
{
|
|
agentCommand.mockClear();
|
|
agentCommand.mockRejectedValueOnce(new Error("boom"));
|
|
|
|
const errorRes = await postChatCompletions(port, {
|
|
stream: true,
|
|
model: "openclaw",
|
|
messages: [{ role: "user", content: "hi" }],
|
|
});
|
|
expect(errorRes.status).toBe(200);
|
|
const errorText = await errorRes.text();
|
|
const errorData = parseSseDataLines(errorText);
|
|
expect(errorData[errorData.length - 1]).toBe("[DONE]");
|
|
|
|
const errorChunks = errorData
|
|
.filter((d) => d !== "[DONE]")
|
|
.map((d) => JSON.parse(d) as Record<string, unknown>);
|
|
const stopChoice = errorChunks
|
|
.flatMap((c) => (c.choices as Array<Record<string, unknown>> | undefined) ?? [])
|
|
.find((choice) => choice.finish_reason === "stop");
|
|
expect((stopChoice?.delta as Record<string, unknown> | undefined)?.content).toBe(
|
|
"Error: internal error",
|
|
);
|
|
}
|
|
} finally {
|
|
// shared server
|
|
}
|
|
});
|
|
|
|
it(
|
|
"sends an initial SSE chunk before a streaming agent run settles",
|
|
{ timeout: 15_000 },
|
|
async () => {
|
|
const port = enabledPort;
|
|
let serverAbortSignal: AbortSignal | undefined;
|
|
|
|
agentCommand.mockClear();
|
|
agentCommand.mockImplementationOnce(
|
|
(opts: unknown) =>
|
|
new Promise<undefined>((resolve) => {
|
|
const signal = (opts as { abortSignal?: AbortSignal } | undefined)?.abortSignal;
|
|
serverAbortSignal = signal;
|
|
if (signal?.aborted) {
|
|
resolve(undefined);
|
|
return;
|
|
}
|
|
signal?.addEventListener("abort", () => resolve(undefined), { once: true });
|
|
}),
|
|
);
|
|
|
|
let settled = false;
|
|
const firstChunk = new Promise<string>((resolve, reject) => {
|
|
const clientReq = http.request(
|
|
{
|
|
hostname: "127.0.0.1",
|
|
port,
|
|
path: "/v1/chat/completions",
|
|
method: "POST",
|
|
headers: {
|
|
"content-type": "application/json",
|
|
authorization: "Bearer secret",
|
|
},
|
|
},
|
|
(res) => {
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.headers["content-type"] ?? "").toContain("text/event-stream");
|
|
res.setEncoding("utf8");
|
|
res.once("data", (chunk) => {
|
|
settled = true;
|
|
resolve(String(chunk));
|
|
clientReq.destroy();
|
|
});
|
|
},
|
|
);
|
|
clientReq.on("error", (err) => {
|
|
if (!settled) {
|
|
reject(err);
|
|
}
|
|
});
|
|
clientReq.setTimeout(2_000, () => {
|
|
if (!settled) {
|
|
settled = true;
|
|
clientReq.destroy(new Error("timed out waiting for first SSE chunk"));
|
|
}
|
|
});
|
|
clientReq.end(
|
|
JSON.stringify({
|
|
stream: true,
|
|
model: "openclaw",
|
|
messages: [{ role: "user", content: "hi" }],
|
|
}),
|
|
);
|
|
});
|
|
|
|
await expect(firstChunk).resolves.toContain('"role":"assistant"');
|
|
await vi.waitFor(() => {
|
|
expect(agentCommand).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
await vi.waitFor(
|
|
() => {
|
|
expect(serverAbortSignal?.aborted).toBe(true);
|
|
},
|
|
{ timeout: 5_000, interval: 50 },
|
|
);
|
|
},
|
|
);
|
|
|
|
it("includes usage in final stream chunk when stream_options.include_usage=true", async () => {
|
|
const port = enabledPort;
|
|
agentCommand.mockClear();
|
|
agentCommand.mockImplementationOnce((async (opts: unknown) => {
|
|
const runId = (opts as { runId?: string } | undefined)?.runId ?? "";
|
|
emitAgentEvent({ runId, stream: "assistant", data: { delta: "he" } });
|
|
emitAgentEvent({ runId, stream: "assistant", data: { delta: "llo" } });
|
|
return {
|
|
payloads: [{ text: "hello" }],
|
|
meta: {
|
|
agentMeta: {
|
|
usage: {
|
|
input: 12,
|
|
output: 5,
|
|
cacheRead: 3,
|
|
cacheWrite: 0,
|
|
total: 20,
|
|
},
|
|
},
|
|
},
|
|
};
|
|
}) as never);
|
|
|
|
const res = await postChatCompletions(port, {
|
|
stream: true,
|
|
stream_options: { include_usage: true },
|
|
model: "openclaw",
|
|
messages: [{ role: "user", content: "hi" }],
|
|
});
|
|
expect(res.status).toBe(200);
|
|
|
|
const text = await res.text();
|
|
const data = parseSseDataLines(text);
|
|
expect(data[data.length - 1]).toBe("[DONE]");
|
|
const jsonChunks = data
|
|
.filter((d) => d !== "[DONE]")
|
|
.map((d) => JSON.parse(d) as Record<string, unknown>);
|
|
|
|
const usageChunk = jsonChunks.find((chunk) => "usage" in chunk);
|
|
expect(usageChunk?.usage).toEqual({
|
|
prompt_tokens: 15,
|
|
completion_tokens: 5,
|
|
total_tokens: 20,
|
|
prompt_tokens_details: { cached_tokens: 3 },
|
|
});
|
|
expect(usageChunk?.choices).toStrictEqual([]);
|
|
});
|
|
|
|
it("keeps aggregate-only usage total in final stream usage chunk", async () => {
|
|
const port = enabledPort;
|
|
agentCommand.mockClear();
|
|
agentCommand.mockImplementationOnce((async (opts: unknown) => {
|
|
const runId = (opts as { runId?: string } | undefined)?.runId ?? "";
|
|
emitAgentEvent({ runId, stream: "assistant", data: { delta: "hello" } });
|
|
return {
|
|
payloads: [{ text: "hello" }],
|
|
meta: {
|
|
agentMeta: {
|
|
usage: {
|
|
total: 123,
|
|
},
|
|
},
|
|
},
|
|
};
|
|
}) as never);
|
|
|
|
const res = await postChatCompletions(port, {
|
|
stream: true,
|
|
stream_options: { include_usage: true },
|
|
model: "openclaw",
|
|
messages: [{ role: "user", content: "hi" }],
|
|
});
|
|
expect(res.status).toBe(200);
|
|
|
|
const text = await res.text();
|
|
const data = parseSseDataLines(text);
|
|
expect(data[data.length - 1]).toBe("[DONE]");
|
|
const jsonChunks = data
|
|
.filter((d) => d !== "[DONE]")
|
|
.map((d) => JSON.parse(d) as Record<string, unknown>);
|
|
const usageChunk = jsonChunks.find((chunk) => "usage" in chunk);
|
|
expect(usageChunk?.usage).toEqual({
|
|
prompt_tokens: 0,
|
|
completion_tokens: 0,
|
|
total_tokens: 123,
|
|
});
|
|
});
|
|
|
|
it("finalizes stream when lifecycle end arrives before usage is available", async () => {
|
|
const port = enabledPort;
|
|
agentCommand.mockClear();
|
|
agentCommand.mockImplementationOnce(
|
|
((opts: unknown) =>
|
|
new Promise((resolve) => {
|
|
const runId = (opts as { runId?: string } | undefined)?.runId ?? "";
|
|
emitAgentEvent({ runId, stream: "assistant", data: { delta: "hello" } });
|
|
emitAgentEvent({ runId, stream: "lifecycle", data: { phase: "end" } });
|
|
setTimeout(() => {
|
|
resolve({
|
|
payloads: [{ text: "hello" }],
|
|
meta: {
|
|
agentMeta: {
|
|
usage: { input: 7, output: 3, total: 10 },
|
|
},
|
|
},
|
|
});
|
|
}, 100);
|
|
})) as never,
|
|
);
|
|
|
|
const res = await postChatCompletions(port, {
|
|
stream: true,
|
|
stream_options: { include_usage: true },
|
|
model: "openclaw",
|
|
messages: [{ role: "user", content: "hi" }],
|
|
});
|
|
expect(res.status).toBe(200);
|
|
|
|
const text = await res.text();
|
|
const data = parseSseDataLines(text);
|
|
expect(data[data.length - 1]).toBe("[DONE]");
|
|
const jsonChunks = data
|
|
.filter((d) => d !== "[DONE]")
|
|
.map((d) => JSON.parse(d) as Record<string, unknown>);
|
|
const usageChunk = jsonChunks.find((chunk) => "usage" in chunk);
|
|
expect(usageChunk?.usage).toEqual({
|
|
prompt_tokens: 7,
|
|
completion_tokens: 3,
|
|
total_tokens: 10,
|
|
});
|
|
});
|
|
|
|
it(
|
|
"cleans up usage-enabled stream when client disconnects before usage arrives",
|
|
{ timeout: 15_000 },
|
|
async () => {
|
|
const port = enabledPort;
|
|
let serverAbortSignal: AbortSignal | undefined;
|
|
|
|
agentCommand.mockClear();
|
|
agentCommand.mockImplementationOnce(
|
|
(opts: unknown) =>
|
|
new Promise<undefined>((resolve) => {
|
|
const runId = (opts as { runId?: string } | undefined)?.runId ?? "";
|
|
const signal = (opts as { abortSignal?: AbortSignal } | undefined)?.abortSignal;
|
|
serverAbortSignal = signal;
|
|
emitAgentEvent({ runId, stream: "assistant", data: { delta: "hello" } });
|
|
emitAgentEvent({ runId, stream: "lifecycle", data: { phase: "end" } });
|
|
if (signal?.aborted) {
|
|
resolve(undefined);
|
|
return;
|
|
}
|
|
signal?.addEventListener("abort", () => resolve(undefined), { once: true });
|
|
}),
|
|
);
|
|
|
|
const clientReq = http.request({
|
|
hostname: "127.0.0.1",
|
|
port,
|
|
path: "/v1/chat/completions",
|
|
method: "POST",
|
|
headers: {
|
|
"content-type": "application/json",
|
|
authorization: "Bearer secret",
|
|
},
|
|
});
|
|
clientReq.on("error", () => {});
|
|
clientReq.end(
|
|
JSON.stringify({
|
|
stream: true,
|
|
stream_options: { include_usage: true },
|
|
model: "openclaw",
|
|
messages: [{ role: "user", content: "hi" }],
|
|
}),
|
|
);
|
|
|
|
await vi.waitFor(() => {
|
|
expect(agentCommand).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
clientReq.destroy();
|
|
|
|
await vi.waitFor(
|
|
() => {
|
|
expect(serverAbortSignal?.aborted).toBe(true);
|
|
},
|
|
{ timeout: 5_000, interval: 50 },
|
|
);
|
|
},
|
|
);
|
|
|
|
it("does not require usage to finalize when include_usage is not requested", async () => {
|
|
const port = enabledPort;
|
|
agentCommand.mockClear();
|
|
agentCommand.mockImplementationOnce(
|
|
((opts: unknown) =>
|
|
new Promise((resolve) => {
|
|
const runId = (opts as { runId?: string } | undefined)?.runId ?? "";
|
|
emitAgentEvent({ runId, stream: "assistant", data: { delta: "hello" } });
|
|
emitAgentEvent({ runId, stream: "lifecycle", data: { phase: "end" } });
|
|
setTimeout(() => {
|
|
resolve({ payloads: [{ text: "hello" }] });
|
|
}, 100);
|
|
})) as never,
|
|
);
|
|
|
|
const res = await postChatCompletions(port, {
|
|
stream: true,
|
|
model: "openclaw",
|
|
messages: [{ role: "user", content: "hi" }],
|
|
});
|
|
expect(res.status).toBe(200);
|
|
|
|
const text = await res.text();
|
|
const data = parseSseDataLines(text);
|
|
expect(data[data.length - 1]).toBe("[DONE]");
|
|
const jsonChunks = data
|
|
.filter((d) => d !== "[DONE]")
|
|
.map((d) => JSON.parse(d) as Record<string, unknown>);
|
|
const usageChunks = jsonChunks.filter((chunk) => "usage" in chunk);
|
|
expect(usageChunks).toHaveLength(0);
|
|
});
|
|
|
|
it("accepts shared-secret bearer callers", async () => {
|
|
const port = await getFreePort();
|
|
const server = await startTokenServer(port);
|
|
try {
|
|
agentCommand.mockClear();
|
|
agentCommand.mockResolvedValueOnce({ payloads: [{ text: "hello" }] } as never);
|
|
|
|
const res = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, {
|
|
method: "POST",
|
|
headers: {
|
|
authorization: "Bearer secret",
|
|
"content-type": "application/json",
|
|
"x-openclaw-scopes": "operator.approvals",
|
|
},
|
|
body: JSON.stringify({
|
|
model: "openclaw",
|
|
messages: [{ role: "user", content: "hi" }],
|
|
}),
|
|
});
|
|
|
|
expect(res.status).toBe(200);
|
|
await res.text();
|
|
} finally {
|
|
await server.close({ reason: "openai token auth owner test done" });
|
|
}
|
|
});
|
|
|
|
it("aborts agent command when streaming client disconnects", { timeout: 15_000 }, async () => {
|
|
const port = enabledPort;
|
|
let serverAbortSignal: AbortSignal | undefined;
|
|
|
|
agentCommand.mockClear();
|
|
agentCommand.mockImplementationOnce(
|
|
(opts: unknown) =>
|
|
new Promise<undefined>((resolve) => {
|
|
const signal = (opts as { abortSignal?: AbortSignal } | undefined)?.abortSignal;
|
|
serverAbortSignal = signal;
|
|
if (signal?.aborted) {
|
|
resolve(undefined);
|
|
return;
|
|
}
|
|
signal?.addEventListener("abort", () => resolve(undefined), { once: true });
|
|
}),
|
|
);
|
|
|
|
const clientReq = http.request({
|
|
hostname: "127.0.0.1",
|
|
port,
|
|
path: "/v1/chat/completions",
|
|
method: "POST",
|
|
headers: {
|
|
"content-type": "application/json",
|
|
authorization: "Bearer secret",
|
|
},
|
|
});
|
|
clientReq.on("error", () => {});
|
|
clientReq.end(
|
|
JSON.stringify({
|
|
stream: true,
|
|
model: "openclaw",
|
|
messages: [{ role: "user", content: "hi" }],
|
|
}),
|
|
);
|
|
|
|
await vi.waitFor(() => {
|
|
expect(agentCommand).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
clientReq.destroy();
|
|
|
|
await vi.waitFor(
|
|
() => {
|
|
expect(serverAbortSignal?.aborted).toBe(true);
|
|
},
|
|
{ timeout: 5_000, interval: 50 },
|
|
);
|
|
});
|
|
|
|
it(
|
|
"aborts agent command when non-streaming client disconnects",
|
|
{ timeout: 15_000 },
|
|
async () => {
|
|
const port = enabledPort;
|
|
let serverAbortSignal: AbortSignal | undefined;
|
|
|
|
agentCommand.mockClear();
|
|
agentCommand.mockImplementationOnce(
|
|
(opts: unknown) =>
|
|
new Promise<undefined>((resolve) => {
|
|
const signal = (opts as { abortSignal?: AbortSignal } | undefined)?.abortSignal;
|
|
serverAbortSignal = signal;
|
|
if (signal?.aborted) {
|
|
resolve(undefined);
|
|
return;
|
|
}
|
|
signal?.addEventListener("abort", () => resolve(undefined), { once: true });
|
|
}),
|
|
);
|
|
|
|
const clientReq = http.request({
|
|
hostname: "127.0.0.1",
|
|
port,
|
|
path: "/v1/chat/completions",
|
|
method: "POST",
|
|
headers: {
|
|
"content-type": "application/json",
|
|
authorization: "Bearer secret",
|
|
},
|
|
});
|
|
clientReq.on("error", () => {});
|
|
clientReq.end(
|
|
JSON.stringify({
|
|
model: "openclaw",
|
|
messages: [{ role: "user", content: "hi" }],
|
|
}),
|
|
);
|
|
|
|
await vi.waitFor(() => {
|
|
expect(agentCommand).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
clientReq.destroy();
|
|
|
|
await vi.waitFor(
|
|
() => {
|
|
expect(serverAbortSignal?.aborted).toBe(true);
|
|
},
|
|
{ timeout: 5_000, interval: 50 },
|
|
);
|
|
},
|
|
);
|
|
});
|