fix: make OpenAI payload guard content-aware

This commit is contained in:
Onur Solmaz
2026-05-25 19:25:36 +08:00
committed by clawsweeper
parent b25d9a0769
commit 26813952b6
6 changed files with 127 additions and 24 deletions
@@ -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;
});
}
+3 -1
View File
@@ -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");
});
+3 -12
View File
@@ -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";
}