mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 21:07:01 -06:00
fix: make OpenAI payload guard content-aware
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { hasOpenAICompatibleConversationTurn } from "./openai-compatible-conversation-turn.js";
|
||||
|
||||
describe("hasOpenAICompatibleConversationTurn", () => {
|
||||
it("rejects missing, system-only, and tool-only payloads", () => {
|
||||
expect(hasOpenAICompatibleConversationTurn(undefined)).toBe(false);
|
||||
expect(hasOpenAICompatibleConversationTurn([{ role: "system", content: "policy" }])).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
hasOpenAICompatibleConversationTurn([
|
||||
{ role: "system", content: "policy" },
|
||||
{ role: "tool", content: "tool output", tool_call_id: "call_1" },
|
||||
]),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects empty user and assistant placeholders", () => {
|
||||
expect(hasOpenAICompatibleConversationTurn([{ role: "user", content: "" }])).toBe(false);
|
||||
expect(hasOpenAICompatibleConversationTurn([{ role: "user", content: " " }])).toBe(false);
|
||||
expect(hasOpenAICompatibleConversationTurn([{ role: "assistant", content: null }])).toBe(false);
|
||||
expect(hasOpenAICompatibleConversationTurn([{ role: "assistant", content: [] }])).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts non-empty user and assistant content", () => {
|
||||
expect(hasOpenAICompatibleConversationTurn([{ role: "user", content: "hello" }])).toBe(true);
|
||||
expect(
|
||||
hasOpenAICompatibleConversationTurn([
|
||||
{ role: "user", content: [{ type: "text", text: "hello" }] },
|
||||
]),
|
||||
).toBe(true);
|
||||
expect(
|
||||
hasOpenAICompatibleConversationTurn([
|
||||
{ role: "user", content: [{ type: "image_url", image_url: { url: "data:image/png" } }] },
|
||||
]),
|
||||
).toBe(true);
|
||||
expect(hasOpenAICompatibleConversationTurn([{ role: "assistant", content: "answer" }])).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts assistant tool calls even when assistant content is empty", () => {
|
||||
expect(
|
||||
hasOpenAICompatibleConversationTurn([
|
||||
{
|
||||
role: "assistant",
|
||||
content: null,
|
||||
tool_calls: [{ id: "call_1", type: "function", function: { name: "status" } }],
|
||||
},
|
||||
]),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
function hasNonEmptyString(value: unknown): boolean {
|
||||
return typeof value === "string" && value.trim().length > 0;
|
||||
}
|
||||
|
||||
function hasNonEmptyContentPart(part: unknown): boolean {
|
||||
if (!part || typeof part !== "object") {
|
||||
return false;
|
||||
}
|
||||
const record = part as Record<string, unknown>;
|
||||
if (record.type === "text") {
|
||||
return hasNonEmptyString(record.text);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function hasNonEmptyMessageContent(content: unknown): boolean {
|
||||
if (hasNonEmptyString(content)) {
|
||||
return true;
|
||||
}
|
||||
if (!Array.isArray(content)) {
|
||||
return false;
|
||||
}
|
||||
return content.some(hasNonEmptyContentPart);
|
||||
}
|
||||
|
||||
function hasAssistantToolCall(message: Record<string, unknown>): boolean {
|
||||
const toolCalls = message.tool_calls;
|
||||
return (
|
||||
Array.isArray(toolCalls) &&
|
||||
toolCalls.some((toolCall) => {
|
||||
return Boolean(toolCall && typeof toolCall === "object");
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export function hasOpenAICompatibleConversationTurn(messages: unknown): boolean {
|
||||
if (!Array.isArray(messages)) {
|
||||
return false;
|
||||
}
|
||||
return messages.some((message) => {
|
||||
if (!message || typeof message !== "object") {
|
||||
return false;
|
||||
}
|
||||
const record = message as Record<string, unknown>;
|
||||
if (record.role === "user") {
|
||||
return hasNonEmptyMessageContent(record.content);
|
||||
}
|
||||
if (record.role === "assistant") {
|
||||
return hasNonEmptyMessageContent(record.content) || hasAssistantToolCall(record);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
@@ -1062,7 +1062,9 @@ describe("openai transport stream", () => {
|
||||
}
|
||||
|
||||
expect(errorPayload).toMatchObject({ stopReason: "error" });
|
||||
expect(String(errorPayload?.errorMessage)).toContain("contains no user or assistant messages");
|
||||
expect(String(errorPayload?.errorMessage)).toContain(
|
||||
"contains no non-empty user or assistant messages",
|
||||
);
|
||||
expect(String(errorPayload?.errorMessage)).toContain("system/tool-only request");
|
||||
});
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
resolveModelSseDebugMode,
|
||||
} from "./model-transport-debug.js";
|
||||
import { formatModelTransportDebugBaseUrl } from "./model-transport-url.js";
|
||||
import { hasOpenAICompatibleConversationTurn } from "./openai-compatible-conversation-turn.js";
|
||||
import { detectOpenAICompletionsCompat } from "./openai-completions-compat.js";
|
||||
import {
|
||||
flattenCompletionMessagesToStringContent,
|
||||
@@ -2295,21 +2296,11 @@ function assertOpenAICompletionsPayloadHasConversationTurn(
|
||||
model: Model<Api>,
|
||||
): void {
|
||||
const messages = params.messages;
|
||||
if (!Array.isArray(messages)) {
|
||||
return;
|
||||
}
|
||||
const hasConversationTurn = messages.some((message) => {
|
||||
if (!message || typeof message !== "object") {
|
||||
return false;
|
||||
}
|
||||
const role = (message as { role?: unknown }).role;
|
||||
return role === "user" || role === "assistant";
|
||||
});
|
||||
if (hasConversationTurn) {
|
||||
if (!Array.isArray(messages) || hasOpenAICompatibleConversationTurn(messages)) {
|
||||
return;
|
||||
}
|
||||
throw new Error(
|
||||
`OpenAI-compatible chat payload for ${model.provider}/${model.id} contains no 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.`,
|
||||
`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.`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -143,6 +143,19 @@ describe("resolvePromptSubmissionSkipReason", () => {
|
||||
).toBe("empty_prompt_history_images");
|
||||
});
|
||||
|
||||
it("treats empty user and assistant placeholders as empty history", () => {
|
||||
expect(
|
||||
resolvePromptSubmissionSkipReason({
|
||||
prompt: " ",
|
||||
messages: [
|
||||
{ role: "user", content: " " },
|
||||
{ role: "assistant", content: [] },
|
||||
],
|
||||
imageCount: 0,
|
||||
}),
|
||||
).toBe("empty_prompt_history_images");
|
||||
});
|
||||
|
||||
it("allows text or image prompt submissions", () => {
|
||||
expect(
|
||||
resolvePromptSubmissionSkipReason({
|
||||
|
||||
@@ -18,6 +18,7 @@ import { listActiveProcessSessionReferences } from "../../bash-process-reference
|
||||
import { resolveHeartbeatPromptForSystemPrompt } from "../../heartbeat-system-prompt.js";
|
||||
import { buildActiveImageGenerationTaskPromptContextForSession } from "../../image-generation-task-status.js";
|
||||
import { buildActiveMusicGenerationTaskPromptContextForSession } from "../../music-generation-task-status.js";
|
||||
import { hasOpenAICompatibleConversationTurn } from "../../openai-compatible-conversation-turn.js";
|
||||
import { resolveProcessToolScopeKey } from "../../pi-tools.js";
|
||||
import { prependSystemPromptAdditionAfterCacheBoundary } from "../../system-prompt-cache-boundary.js";
|
||||
import { resolveEffectiveToolFsWorkspaceOnly } from "../../tool-fs-policy.js";
|
||||
@@ -242,16 +243,6 @@ export function shouldWarnOnOrphanedUserRepair(
|
||||
|
||||
export type PromptSubmissionSkipReason = "blank_user_prompt" | "empty_prompt_history_images";
|
||||
|
||||
function hasProviderVisibleConversationTurn(messages: readonly unknown[]): boolean {
|
||||
return messages.some((message) => {
|
||||
if (!message || typeof message !== "object") {
|
||||
return false;
|
||||
}
|
||||
const role = (message as { role?: unknown }).role;
|
||||
return role === "user" || role === "assistant";
|
||||
});
|
||||
}
|
||||
|
||||
export function resolvePromptSubmissionSkipReason(params: {
|
||||
prompt: string;
|
||||
messages: readonly unknown[];
|
||||
@@ -261,7 +252,7 @@ export function resolvePromptSubmissionSkipReason(params: {
|
||||
if (params.prompt.trim().length > 0 || params.imageCount > 0) {
|
||||
return null;
|
||||
}
|
||||
return hasProviderVisibleConversationTurn(params.messages)
|
||||
return hasOpenAICompatibleConversationTurn(params.messages)
|
||||
? "blank_user_prompt"
|
||||
: "empty_prompt_history_images";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user