Skip to content

Commit 489e415

Browse files
clawsweeper[bot]TurboTheTurtleosolmaz
authored
Guard OpenAI chat payload turns (#86497)
Summary: - Adds a scoped ModelStudio/DashScope OpenAI-compatible guard for chat payloads with no non-empty user or assi ... turn, shared turn-detection helper coverage, prompt-skip handling, regression tests, and a changelog entry. - PR surface: Source +83, Tests +298, Docs +1. Total +382 across 10 files. - Reproducibility: yes. source-reproducible for the OpenClaw-side malformed payload shape: current main has no ... he exact qwen-long/qwen3-coder-plus provider error was not reproduced with the available DashScope account. Automerge notes: - PR branch already contained follow-up commit before automerge: fix: make OpenAI payload guard content-aware - PR branch already contained follow-up commit before automerge: fix: scope openai payload turn guard - PR branch already contained follow-up commit before automerge: Guard OpenAI chat payload turns Validation: - ClawSweeper review passed for head e16a3fe. - Required merge gates passed before the squash merge. Prepared head SHA: e16a3fe Review: #86497 (comment) Co-authored-by: Andy Ye <35905412+TurboTheTurtle@users.noreply.github.com> Co-authored-by: Onur Solmaz <2453968+osolmaz@users.noreply.github.com> Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com> Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com> Approved-by: osolmaz Co-authored-by: osolmaz <2453968+osolmaz@users.noreply.github.com>
1 parent 459e89a commit 489e415

10 files changed

Lines changed: 383 additions & 1 deletion

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ Docs: https://docs.openclaw.ai
1818
- Gateway: keep session-only Control UI tool-start mirrors flowing during diagnostic queue pressure instead of silently dropping non-terminal tool updates.
1919
- Gateway: avoid sending duplicate tool-event frames to Control UI connections that are subscribed by both run and session.
2020
- Discord/OpenAI voice: accept longer leading wake-name mistranscripts such as "Open Club" for OpenClaw.
21+
- Agents/OpenAI-compatible: stop ModelStudio-compatible chat requests before sending system/tool-only payloads that have no usable user or assistant turn. (#86177) Thanks @TurboTheTurtle.
2122
- Discord/OpenAI voice: accept leading fuzzy wake-name transcripts such as "Monty" or "Moti" for a Molty agent while keeping ambient speech gated.
2223
- Media understanding: convert HEIC and HEIF images to JPEG before image description providers run so iPhone photos work in direct and configured image-description flows. (#86037)
2324
- Agents: release embedded-attempt session locks from outer teardown so post-prompt exceptions cannot wedge later requests behind `SessionWriteLockTimeoutError`. Fixes #86014. Thanks @openperf.
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { describe, expect, it } from "vitest";
2+
import { hasOpenAICompatibleConversationTurn } from "./openai-compatible-conversation-turn.js";
3+
4+
describe("hasOpenAICompatibleConversationTurn", () => {
5+
it("rejects missing, system-only, and tool-only payloads", () => {
6+
expect(hasOpenAICompatibleConversationTurn(undefined)).toBe(false);
7+
expect(hasOpenAICompatibleConversationTurn([{ role: "system", content: "policy" }])).toBe(
8+
false,
9+
);
10+
expect(
11+
hasOpenAICompatibleConversationTurn([
12+
{ role: "system", content: "policy" },
13+
{ role: "tool", content: "tool output", tool_call_id: "call_1" },
14+
]),
15+
).toBe(false);
16+
});
17+
18+
it("rejects empty user and assistant placeholders", () => {
19+
expect(hasOpenAICompatibleConversationTurn([{ role: "user", content: "" }])).toBe(false);
20+
expect(hasOpenAICompatibleConversationTurn([{ role: "user", content: " " }])).toBe(false);
21+
expect(hasOpenAICompatibleConversationTurn([{ role: "assistant", content: null }])).toBe(false);
22+
expect(hasOpenAICompatibleConversationTurn([{ role: "assistant", content: [] }])).toBe(false);
23+
});
24+
25+
it("accepts non-empty user and assistant content", () => {
26+
expect(hasOpenAICompatibleConversationTurn([{ role: "user", content: "hello" }])).toBe(true);
27+
expect(
28+
hasOpenAICompatibleConversationTurn([
29+
{ role: "user", content: [{ type: "text", text: "hello" }] },
30+
]),
31+
).toBe(true);
32+
expect(
33+
hasOpenAICompatibleConversationTurn([
34+
{ role: "user", content: [{ type: "image_url", image_url: { url: "data:image/png" } }] },
35+
]),
36+
).toBe(true);
37+
expect(hasOpenAICompatibleConversationTurn([{ role: "assistant", content: "answer" }])).toBe(
38+
true,
39+
);
40+
});
41+
42+
it("accepts assistant tool calls even when assistant content is empty", () => {
43+
expect(
44+
hasOpenAICompatibleConversationTurn([
45+
{
46+
role: "assistant",
47+
content: null,
48+
tool_calls: [{ id: "call_1", type: "function", function: { name: "status" } }],
49+
},
50+
]),
51+
).toBe(true);
52+
});
53+
});
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
function hasNonEmptyString(value: unknown): boolean {
2+
return typeof value === "string" && value.trim().length > 0;
3+
}
4+
5+
function hasNonEmptyContentPart(part: unknown): boolean {
6+
if (!part || typeof part !== "object") {
7+
return false;
8+
}
9+
const record = part as Record<string, unknown>;
10+
if (record.type === "text") {
11+
return hasNonEmptyString(record.text);
12+
}
13+
return true;
14+
}
15+
16+
function hasNonEmptyMessageContent(content: unknown): boolean {
17+
if (hasNonEmptyString(content)) {
18+
return true;
19+
}
20+
if (!Array.isArray(content)) {
21+
return false;
22+
}
23+
return content.some(hasNonEmptyContentPart);
24+
}
25+
26+
function hasAssistantToolCall(message: Record<string, unknown>): boolean {
27+
const toolCalls = message.tool_calls;
28+
return (
29+
Array.isArray(toolCalls) &&
30+
toolCalls.some((toolCall) => {
31+
return Boolean(toolCall && typeof toolCall === "object");
32+
})
33+
);
34+
}
35+
36+
export function hasOpenAICompatibleConversationTurn(messages: unknown): boolean {
37+
if (!Array.isArray(messages)) {
38+
return false;
39+
}
40+
return messages.some((message) => {
41+
if (!message || typeof message !== "object") {
42+
return false;
43+
}
44+
const record = message as Record<string, unknown>;
45+
if (record.role === "user") {
46+
return hasNonEmptyMessageContent(record.content);
47+
}
48+
if (record.role === "assistant") {
49+
return hasNonEmptyMessageContent(record.content) || hasAssistantToolCall(record);
50+
}
51+
return false;
52+
});
53+
}

src/agents/openai-completions-compat.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,26 @@ describe("resolveOpenAICompletionsCompatDefaults", () => {
8282
expect(defaults.supportsReasoningEffort).toBe(false);
8383
expect(defaults.maxTokensField).toBe("max_tokens");
8484
});
85+
86+
it("requires a non-empty user or assistant turn for ModelStudio-compatible providers", () => {
87+
expect(
88+
resolveOpenAICompletionsCompatDefaults({
89+
provider: "qwen",
90+
endpointClass: "modelstudio-native",
91+
knownProviderFamily: "modelstudio",
92+
}).requiresNonEmptyUserOrAssistantMessage,
93+
).toBe(true);
94+
});
95+
96+
it("does not require a non-empty user or assistant turn for generic local endpoints", () => {
97+
expect(
98+
resolveOpenAICompletionsCompatDefaults({
99+
provider: "vllm",
100+
endpointClass: "local",
101+
knownProviderFamily: "vllm",
102+
}).requiresNonEmptyUserOrAssistantMessage,
103+
).toBe(false);
104+
});
85105
});
86106

87107
describe("detectOpenAICompletionsCompat", () => {

src/agents/openai-completions-compat.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ type OpenAICompletionsCompatDefaults = {
2121
visibleReasoningDetailTypes: string[];
2222
supportsStrictMode: boolean;
2323
requiresReasoningContentOnAssistantMessages: boolean;
24+
requiresNonEmptyUserOrAssistantMessage: boolean;
2425
};
2526

2627
type DetectedOpenAICompletionsCompat = {
@@ -51,6 +52,10 @@ export function resolveOpenAICompletionsCompatDefaults(
5152
knownProviderFamily === "modelstudio" ||
5253
endpointClass === "moonshot-native" ||
5354
endpointClass === "modelstudio-native";
55+
const isModelStudioLike =
56+
knownProviderFamily === "modelstudio" ||
57+
endpointClass === "modelstudio-native" ||
58+
(isDefaultRoute && isDefaultRouteProvider(provider, "dashscope", "modelstudio", "qwen"));
5459
const isZai =
5560
endpointClass === "zai-native" ||
5661
(isDefaultRoute && isDefaultRouteProvider(input.provider, "zai"));
@@ -112,6 +117,7 @@ export function resolveOpenAICompletionsCompatDefaults(
112117
visibleReasoningDetailTypes: isOpenRouterLike ? ["response.output_text", "response.text"] : [],
113118
supportsStrictMode: !isZai && !usesConfiguredNonOpenAIEndpoint,
114119
requiresReasoningContentOnAssistantMessages: isDeepSeek || isXiaomi,
120+
requiresNonEmptyUserOrAssistantMessage: isModelStudioLike,
115121
};
116122
}
117123

src/agents/openai-transport-stream.test.ts

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1028,6 +1028,136 @@ describe("openai transport stream", () => {
10281028
}
10291029
});
10301030

1031+
it("refuses ModelStudio chat streams with no user or assistant payload turns", async () => {
1032+
const model = {
1033+
id: "qwen-coder-plus",
1034+
name: "qwen-coder-plus",
1035+
api: "openai-completions",
1036+
provider: "qwen",
1037+
baseUrl: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1",
1038+
reasoning: false,
1039+
input: ["text"],
1040+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
1041+
contextWindow: 4096,
1042+
maxTokens: 256,
1043+
} satisfies Model<"openai-completions">;
1044+
const stream = createOpenAICompletionsTransportStreamFn()(
1045+
model,
1046+
{
1047+
systemPrompt: "runtime-only system prompt",
1048+
messages: [],
1049+
tools: [],
1050+
} as never,
1051+
{ apiKey: "test-key" } as never,
1052+
);
1053+
1054+
let errorPayload: Record<string, unknown> | undefined;
1055+
for await (const event of stream as AsyncIterable<{
1056+
type: string;
1057+
error?: Record<string, unknown>;
1058+
}>) {
1059+
if (event.type === "error") {
1060+
errorPayload = event.error;
1061+
}
1062+
}
1063+
1064+
expect(errorPayload).toMatchObject({ stopReason: "error" });
1065+
expect(String(errorPayload?.errorMessage)).toContain(
1066+
"contains no non-empty user or assistant messages",
1067+
);
1068+
expect(String(errorPayload?.errorMessage)).toContain("system/tool-only request");
1069+
});
1070+
1071+
it("allows generic OpenAI-compatible chat streams without the ModelStudio turn guard", async () => {
1072+
let capturedRoles: string[] | undefined;
1073+
const server = createServer((req, res) => {
1074+
let body = "";
1075+
req.setEncoding("utf8");
1076+
req.on("data", (chunk) => {
1077+
body += chunk;
1078+
});
1079+
req.on("end", () => {
1080+
const parsed = JSON.parse(body) as { messages?: Array<{ role?: string }> };
1081+
capturedRoles = parsed.messages?.map((message) => message.role ?? "");
1082+
res.writeHead(200, {
1083+
"content-type": "text/event-stream; charset=utf-8",
1084+
"cache-control": "no-cache",
1085+
connection: "keep-alive",
1086+
});
1087+
const created = Math.floor(Date.now() / 1000);
1088+
res.write(
1089+
`data: ${JSON.stringify({
1090+
id: "chatcmpl-system-only",
1091+
object: "chat.completion.chunk",
1092+
created,
1093+
model: "generic-openai-compatible",
1094+
choices: [
1095+
{
1096+
index: 0,
1097+
delta: { role: "assistant", content: "OK" },
1098+
finish_reason: null,
1099+
},
1100+
],
1101+
})}\n\n`,
1102+
);
1103+
res.write(
1104+
`data: ${JSON.stringify({
1105+
id: "chatcmpl-system-only",
1106+
object: "chat.completion.chunk",
1107+
created,
1108+
model: "generic-openai-compatible",
1109+
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
1110+
})}\n\n`,
1111+
);
1112+
res.write("data: [DONE]\n\n");
1113+
res.end();
1114+
});
1115+
});
1116+
1117+
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
1118+
try {
1119+
const address = server.address();
1120+
if (!address || typeof address === "string") {
1121+
throw new Error("Missing loopback server address");
1122+
}
1123+
const model = {
1124+
id: "generic-openai-compatible",
1125+
name: "Generic OpenAI Compatible",
1126+
api: "openai-completions",
1127+
provider: "custom-openai-compatible",
1128+
baseUrl: `http://127.0.0.1:${address.port}/v1`,
1129+
reasoning: false,
1130+
input: ["text"],
1131+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
1132+
contextWindow: 4096,
1133+
maxTokens: 256,
1134+
} satisfies Model<"openai-completions">;
1135+
const stream = createOpenAICompletionsTransportStreamFn()(
1136+
model,
1137+
{
1138+
systemPrompt: "runtime-only system prompt",
1139+
messages: [],
1140+
tools: [],
1141+
} as never,
1142+
{ apiKey: "test-key" } as never,
1143+
);
1144+
1145+
let doneReason: string | undefined;
1146+
for await (const event of stream as AsyncIterable<{ type: string; reason?: string }>) {
1147+
if (event.type === "done") {
1148+
doneReason = event.reason;
1149+
}
1150+
}
1151+
1152+
expect(capturedRoles).toEqual(["system"]);
1153+
expect(doneReason).toBe("stop");
1154+
} finally {
1155+
await new Promise<void>((resolve, reject) => {
1156+
server.close((error) => (error ? reject(error) : resolve()));
1157+
});
1158+
}
1159+
});
1160+
10311161
it("parses JSON chat completions returned to streaming requests", async () => {
10321162
let capturedStreamFlag: unknown;
10331163
const server = createServer((req, res) => {

src/agents/openai-transport-stream.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import {
4040
resolveModelSseDebugMode,
4141
} from "./model-transport-debug.js";
4242
import { formatModelTransportDebugBaseUrl } from "./model-transport-url.js";
43+
import { hasOpenAICompatibleConversationTurn } from "./openai-compatible-conversation-turn.js";
4344
import { detectOpenAICompletionsCompat } from "./openai-completions-compat.js";
4445
import {
4546
flattenCompletionMessagesToStringContent,
@@ -2290,6 +2291,19 @@ function hasToolHistory(messages: Context["messages"]): boolean {
22902291
);
22912292
}
22922293

2294+
function assertOpenAICompletionsPayloadHasConversationTurn(
2295+
params: Record<string, unknown>,
2296+
model: Model<Api>,
2297+
): void {
2298+
const messages = params.messages;
2299+
if (!Array.isArray(messages) || hasOpenAICompatibleConversationTurn(messages)) {
2300+
return;
2301+
}
2302+
throw new Error(
2303+
`OpenAI-compatible chat payload for ${model.provider}/${model.id} contains no non-empty user or assistant messages after compaction and transport transforms; refusing to send a system/tool-only request. Start a new user turn or repair the compacted session history.`,
2304+
);
2305+
}
2306+
22932307
function createOpenAICompletionsClient(
22942308
model: Model<Api>,
22952309
context: Context,
@@ -2405,6 +2419,10 @@ export function createOpenAICompletionsTransportStreamFn(): StreamFn {
24052419
enforceCodeModeResponsesToolSurface(params);
24062420
assertCodeModeResponsesToolSurface(params);
24072421
}
2422+
const compat = getCompat(model as OpenAIModeModel);
2423+
if (compat.requiresNonEmptyUserOrAssistantMessage) {
2424+
assertOpenAICompletionsPayloadHasConversationTurn(params, model);
2425+
}
24082426
const responseStream = (await client.chat.completions.create(
24092427
params as never,
24102428
buildOpenAISdkRequestOptions(model, options?.signal),
@@ -2854,6 +2872,7 @@ function detectCompat(model: OpenAIModeModel) {
28542872
supportsStrictMode: compatDefaults.supportsStrictMode,
28552873
requiresReasoningContentOnAssistantMessages:
28562874
compatDefaults.requiresReasoningContentOnAssistantMessages,
2875+
requiresNonEmptyUserOrAssistantMessage: compatDefaults.requiresNonEmptyUserOrAssistantMessage,
28572876
};
28582877
}
28592878

@@ -2876,6 +2895,7 @@ function getCompat(model: OpenAIModeModel): {
28762895
strictMessageKeys: boolean;
28772896
visibleReasoningDetailTypes: string[];
28782897
requiresReasoningContentOnAssistantMessages: boolean;
2898+
requiresNonEmptyUserOrAssistantMessage: boolean;
28792899
} {
28802900
const detected = detectCompat(model);
28812901
const compat = model.compat ?? {};
@@ -2909,6 +2929,7 @@ function getCompat(model: OpenAIModeModel): {
29092929
compat.visibleReasoningDetailTypes ?? detected.visibleReasoningDetailTypes,
29102930
requiresReasoningContentOnAssistantMessages:
29112931
detected.requiresReasoningContentOnAssistantMessages,
2932+
requiresNonEmptyUserOrAssistantMessage: detected.requiresNonEmptyUserOrAssistantMessage,
29122933
};
29132934
}
29142935

0 commit comments

Comments
 (0)