diff --git a/core/llm/countTokens.ts b/core/llm/countTokens.ts index 11cfc6cb2d9..5395fe3b208 100644 --- a/core/llm/countTokens.ts +++ b/core/llm/countTokens.ts @@ -9,7 +9,7 @@ import { } from "../index.js"; import { autodetectTemplateType } from "./autodetect.js"; import { - addSpaceToAnyEmptyMessages, + stripEmptyContentParts, chatMessageIsEmpty, isUserOrToolMsg, messageHasToolCallId, @@ -446,7 +446,7 @@ function compileChatMessages({ // Remove any empty messages or non-user/tool trailing messages msgsCopy = msgsCopy.filter((msg) => !chatMessageIsEmpty(msg)); - msgsCopy = addSpaceToAnyEmptyMessages(msgsCopy); + msgsCopy = stripEmptyContentParts(msgsCopy); // Extract the tool sequence from the end of the message array const toolSequence = extractToolSequence(msgsCopy); diff --git a/core/llm/llms/Bedrock.ts b/core/llm/llms/Bedrock.ts index 98ad4e6f3f7..c6c2cd6d588 100644 --- a/core/llm/llms/Bedrock.ts +++ b/core/llm/llms/Bedrock.ts @@ -538,11 +538,15 @@ class Bedrock extends BaseLLM { ): ContentBlock[] { const blocks: ContentBlock[] = []; if (typeof content === "string") { - blocks.push({ text: content }); + if (content.trim()) { + blocks.push({ text: content }); + } } else { for (const part of content) { if (part.type === "text") { - blocks.push({ text: part.text }); + if (part.text?.trim()) { + blocks.push({ text: part.text }); + } } else if (part.type === "imageUrl" && part.imageUrl) { const parsed = parseDataUrl(part.imageUrl.url); if (parsed) { diff --git a/core/llm/messages.ts b/core/llm/messages.ts index 1ddc83e56be..9a6ce7f4964 100644 --- a/core/llm/messages.ts +++ b/core/llm/messages.ts @@ -4,25 +4,36 @@ export function messageHasToolCalls(msg: ChatMessage): boolean { return msg.role === "assistant" && !!msg.toolCalls; } -export function messageIsEmpty(message: ChatMessage): boolean { - if (typeof message.content === "string") { - return message.content.trim() === ""; +function contentIsEmpty(content: ChatMessage["content"]): boolean { + if (typeof content === "string") { + return content.trim() === ""; } - if (Array.isArray(message.content)) { - return message.content.every( - (item) => item.type === "text" && item.text?.trim() === "", + if (Array.isArray(content)) { + return content.every( + (item) => item.type === "text" && (!item.text || item.text.trim() === ""), ); } - return false; + return !content; +} + +export function messageIsEmpty(message: ChatMessage): boolean { + return contentIsEmpty(message.content); } -// some providers don't support empty messages -export function addSpaceToAnyEmptyMessages( - messages: ChatMessage[], -): ChatMessage[] { +/** Strip empty text parts from array content. */ +export function stripEmptyContentParts(messages: ChatMessage[]): ChatMessage[] { return messages.map((message) => { - if (messageIsEmpty(message)) { - message.content = " "; + if (Array.isArray(message.content)) { + message.content = message.content.filter( + (part) => + part.type !== "text" || + (part.text !== null && + part.text !== undefined && + part.text.trim() !== ""), + ); + if (message.content.length === 0) { + message.content = ""; + } } return message; }); @@ -57,15 +68,9 @@ export function chatMessageIsEmpty(message: ChatMessage): boolean { switch (message.role) { case "system": case "user": - return ( - typeof message.content === "string" && message.content.trim() === "" - ); + return contentIsEmpty(message.content); case "assistant": - return ( - typeof message.content === "string" && - message.content.trim() === "" && - !message.toolCalls - ); + return contentIsEmpty(message.content) && !message.toolCalls; case "thinking": case "tool": return false; diff --git a/core/llm/openaiTypeConverters.ts b/core/llm/openaiTypeConverters.ts index fb4673e11be..a44be9d9dea 100644 --- a/core/llm/openaiTypeConverters.ts +++ b/core/llm/openaiTypeConverters.ts @@ -106,6 +106,18 @@ function appendReasoningFieldsIfSupported( } } +function toAssistantContent( + content: ChatMessage["content"], +): string | TextMessagePart[] { + if (typeof content === "string") { + return content || " "; // LM Studio (and other providers) don't accept empty content + } + const parts = content + .filter((p) => p.type === "text" && p.text?.trim()) + .map((p) => p as TextMessagePart); + return parts.length > 0 ? parts : " "; +} + export function toChatMessage( message: ChatMessage, options: CompletionOptions, @@ -145,12 +157,7 @@ export function toChatMessage( }[]; } = { role: "assistant", - content: - typeof message.content === "string" - ? message.content || " " // LM Studio (and other providers) don't accept empty content - : message.content - .filter((part) => part.type === "text") - .map((part) => part as TextMessagePart), + content: toAssistantContent(message.content), }; // Add tool calls if present @@ -178,31 +185,45 @@ export function toChatMessage( if (typeof message.content === "string") { return { role: "user", - content: message.content ?? " ", // LM Studio (and other providers) don't accept empty content + content: message.content || " ", }; } // If no multi-media is in the message, just send as text // for compatibility with OpenAI-"compatible" servers // that don't support multi-media format + const hasNonText = message.content.some((item) => item.type !== "text"); + if (hasNonText) { + // Filter out empty text parts when there's non-text content (images) + const filteredContent = message.content + .filter( + (part) => + part.type !== "text" || (part.text && part.text.trim() !== ""), + ) + .map((part) => { + if (part.type === "imageUrl") { + return { + type: "image_url" as const, + image_url: { + url: part.imageUrl.url, + detail: "auto" as const, + }, + }; + } + return part; + }); + return { + role: "user", + content: filteredContent, + }; + } + return { role: "user", - content: message.content.some((item) => item.type !== "text") - ? message.content.map((part) => { - if (part.type === "imageUrl") { - return { - type: "image_url" as const, - image_url: { - url: part.imageUrl.url, - detail: "auto" as const, - }, - }; - } - return part; - }) - : message.content - .map((item) => (item as TextMessagePart).text) - .join("") || " ", + content: + message.content + .map((item) => (item as TextMessagePart).text) + .join("") || "...", }; } } diff --git a/packages/openai-adapters/src/apis/Bedrock.ts b/packages/openai-adapters/src/apis/Bedrock.ts index d98ad25d2f5..72518af7e1f 100644 --- a/packages/openai-adapters/src/apis/Bedrock.ts +++ b/packages/openai-adapters/src/apis/Bedrock.ts @@ -203,9 +203,15 @@ export class BedrockApi implements BaseLlmApi { const content = message.content; if (content) { if (typeof content === "string") { - currentBlocks.push({ text: content }); + if (content.trim()) { + currentBlocks.push({ text: content }); + } } else { content.forEach((part) => { + // Skip empty text parts + if (part.type === "text" && !part.text?.trim()) { + return; + } currentBlocks.push(this._oaiPartToBedrockPart(part)); }); }