mirror of
https://github.com/lobehub/lobehub.git
synced 2026-03-27 13:29:15 +07:00
🐛 fix(model-runtime): handle null content in anthropic message builder (#11756)
* 🐛 fix(model-runtime): handle null content in anthropic message builder Fix TypeError when building Anthropic messages with null content: - Handle assistant messages with tool_calls but null content - Handle tool messages with null or empty string content - Use '<empty_content>' placeholder for null/empty content Add 3 test cases covering the null content scenarios. Closes: LOBE-4201, LOBE-2715 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * 🐛 fix(model-runtime): handle array content in tool messages Tool messages may have array content, not just string. Use buildArrayContent to properly process array content in tool results. Add test case for tool message with array content. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * 🐛 fix(model-runtime): filter out null/empty content in assistant messages When assistant message has tool_calls but null/empty content, filter out the empty text block instead of using placeholder. Only tool_use blocks remain in the content array. Add test case for empty string content scenario. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * ✅ test(model-runtime): add test case for tool message with image content Add test case to verify tool message with array content containing both text and image is correctly processed. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * ✅ test(model-runtime): add tests for orphan tool message with null/empty content Add test cases for tool messages without corresponding assistant tool_call when content is null or empty string. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -281,6 +281,62 @@ describe('anthropicHelpers', () => {
|
||||
const result = await buildAnthropicMessage(message);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle assistant message with tool_calls but null content', async () => {
|
||||
const message: OpenAIChatMessage = {
|
||||
content: null as any,
|
||||
role: 'assistant',
|
||||
tool_calls: [
|
||||
{
|
||||
id: 'call1',
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'search_people',
|
||||
arguments: '{"location":"Singapore"}',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
const result = await buildAnthropicMessage(message);
|
||||
expect(result!.role).toBe('assistant');
|
||||
// null content should be filtered out, only tool_use remains
|
||||
expect(result!.content).toEqual([
|
||||
{
|
||||
id: 'call1',
|
||||
input: { location: 'Singapore' },
|
||||
name: 'search_people',
|
||||
type: 'tool_use',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle assistant message with tool_calls but empty string content', async () => {
|
||||
const message: OpenAIChatMessage = {
|
||||
content: '',
|
||||
role: 'assistant',
|
||||
tool_calls: [
|
||||
{
|
||||
id: 'call1',
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'search_people',
|
||||
arguments: '{"location":"Singapore"}',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
const result = await buildAnthropicMessage(message);
|
||||
expect(result!.role).toBe('assistant');
|
||||
// empty string content should be filtered out, only tool_use remains
|
||||
expect(result!.content).toEqual([
|
||||
{
|
||||
id: 'call1',
|
||||
input: { location: 'Singapore' },
|
||||
name: 'search_people',
|
||||
type: 'tool_use',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildAnthropicMessages', () => {
|
||||
@@ -526,6 +582,320 @@ describe('anthropicHelpers', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle tool message with null content', async () => {
|
||||
const messages: OpenAIChatMessage[] = [
|
||||
{
|
||||
content: '搜索人员',
|
||||
role: 'user',
|
||||
},
|
||||
{
|
||||
content: '正在搜索...',
|
||||
role: 'assistant',
|
||||
tool_calls: [
|
||||
{
|
||||
function: {
|
||||
arguments: '{"location": "Singapore"}',
|
||||
name: 'search_people',
|
||||
},
|
||||
id: 'toolu_01CnXPcBEqsGGbvRriem3Rth',
|
||||
type: 'function',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
content: null as any,
|
||||
name: 'search_people',
|
||||
role: 'tool',
|
||||
tool_call_id: 'toolu_01CnXPcBEqsGGbvRriem3Rth',
|
||||
},
|
||||
];
|
||||
|
||||
const contents = await buildAnthropicMessages(messages);
|
||||
|
||||
expect(contents).toEqual([
|
||||
{ content: '搜索人员', role: 'user' },
|
||||
{
|
||||
content: [
|
||||
{ text: '正在搜索...', type: 'text' },
|
||||
{
|
||||
id: 'toolu_01CnXPcBEqsGGbvRriem3Rth',
|
||||
input: { location: 'Singapore' },
|
||||
name: 'search_people',
|
||||
type: 'tool_use',
|
||||
},
|
||||
],
|
||||
role: 'assistant',
|
||||
},
|
||||
{
|
||||
content: [
|
||||
{
|
||||
content: [{ text: '<empty_content>', type: 'text' }],
|
||||
tool_use_id: 'toolu_01CnXPcBEqsGGbvRriem3Rth',
|
||||
type: 'tool_result',
|
||||
},
|
||||
],
|
||||
role: 'user',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle tool message with empty string content', async () => {
|
||||
const messages: OpenAIChatMessage[] = [
|
||||
{
|
||||
content: '搜索人员',
|
||||
role: 'user',
|
||||
},
|
||||
{
|
||||
content: '正在搜索...',
|
||||
role: 'assistant',
|
||||
tool_calls: [
|
||||
{
|
||||
function: {
|
||||
arguments: '{"location": "Singapore"}',
|
||||
name: 'search_people',
|
||||
},
|
||||
id: 'toolu_01CnXPcBEqsGGbvRriem3Rth',
|
||||
type: 'function',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
content: '',
|
||||
name: 'search_people',
|
||||
role: 'tool',
|
||||
tool_call_id: 'toolu_01CnXPcBEqsGGbvRriem3Rth',
|
||||
},
|
||||
];
|
||||
|
||||
const contents = await buildAnthropicMessages(messages);
|
||||
|
||||
expect(contents).toEqual([
|
||||
{ content: '搜索人员', role: 'user' },
|
||||
{
|
||||
content: [
|
||||
{ text: '正在搜索...', type: 'text' },
|
||||
{
|
||||
id: 'toolu_01CnXPcBEqsGGbvRriem3Rth',
|
||||
input: { location: 'Singapore' },
|
||||
name: 'search_people',
|
||||
type: 'tool_use',
|
||||
},
|
||||
],
|
||||
role: 'assistant',
|
||||
},
|
||||
{
|
||||
content: [
|
||||
{
|
||||
content: [{ text: '<empty_content>', type: 'text' }],
|
||||
tool_use_id: 'toolu_01CnXPcBEqsGGbvRriem3Rth',
|
||||
type: 'tool_result',
|
||||
},
|
||||
],
|
||||
role: 'user',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle tool message with array content', async () => {
|
||||
const messages: OpenAIChatMessage[] = [
|
||||
{
|
||||
content: '搜索人员',
|
||||
role: 'user',
|
||||
},
|
||||
{
|
||||
content: '正在搜索...',
|
||||
role: 'assistant',
|
||||
tool_calls: [
|
||||
{
|
||||
function: {
|
||||
arguments: '{"location": "Singapore"}',
|
||||
name: 'search_people',
|
||||
},
|
||||
id: 'toolu_01CnXPcBEqsGGbvRriem3Rth',
|
||||
type: 'function',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
content: [
|
||||
{ type: 'text', text: 'Found 5 candidates' },
|
||||
{ type: 'text', text: 'Result details here' },
|
||||
] as any,
|
||||
name: 'search_people',
|
||||
role: 'tool',
|
||||
tool_call_id: 'toolu_01CnXPcBEqsGGbvRriem3Rth',
|
||||
},
|
||||
];
|
||||
|
||||
const contents = await buildAnthropicMessages(messages);
|
||||
|
||||
expect(contents).toEqual([
|
||||
{ content: '搜索人员', role: 'user' },
|
||||
{
|
||||
content: [
|
||||
{ text: '正在搜索...', type: 'text' },
|
||||
{
|
||||
id: 'toolu_01CnXPcBEqsGGbvRriem3Rth',
|
||||
input: { location: 'Singapore' },
|
||||
name: 'search_people',
|
||||
type: 'tool_use',
|
||||
},
|
||||
],
|
||||
role: 'assistant',
|
||||
},
|
||||
{
|
||||
content: [
|
||||
{
|
||||
content: [
|
||||
{ type: 'text', text: 'Found 5 candidates' },
|
||||
{ type: 'text', text: 'Result details here' },
|
||||
],
|
||||
tool_use_id: 'toolu_01CnXPcBEqsGGbvRriem3Rth',
|
||||
type: 'tool_result',
|
||||
},
|
||||
],
|
||||
role: 'user',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle tool message with array content containing image', async () => {
|
||||
vi.mocked(parseDataUri).mockReturnValueOnce({
|
||||
mimeType: 'image/png',
|
||||
base64: 'screenshotBase64Data',
|
||||
type: 'base64',
|
||||
});
|
||||
|
||||
const messages: OpenAIChatMessage[] = [
|
||||
{
|
||||
content: '截图分析',
|
||||
role: 'user',
|
||||
},
|
||||
{
|
||||
content: '正在截图...',
|
||||
role: 'assistant',
|
||||
tool_calls: [
|
||||
{
|
||||
function: {
|
||||
arguments: '{"url": "https://example.com"}',
|
||||
name: 'screenshot',
|
||||
},
|
||||
id: 'toolu_screenshot_123',
|
||||
type: 'function',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
content: [
|
||||
{ type: 'text', text: 'Screenshot captured' },
|
||||
{
|
||||
type: 'image_url',
|
||||
image_url: { url: 'data:image/png;base64,screenshotBase64Data' },
|
||||
},
|
||||
] as any,
|
||||
name: 'screenshot',
|
||||
role: 'tool',
|
||||
tool_call_id: 'toolu_screenshot_123',
|
||||
},
|
||||
];
|
||||
|
||||
const contents = await buildAnthropicMessages(messages);
|
||||
|
||||
expect(contents).toEqual([
|
||||
{ content: '截图分析', role: 'user' },
|
||||
{
|
||||
content: [
|
||||
{ text: '正在截图...', type: 'text' },
|
||||
{
|
||||
id: 'toolu_screenshot_123',
|
||||
input: { url: 'https://example.com' },
|
||||
name: 'screenshot',
|
||||
type: 'tool_use',
|
||||
},
|
||||
],
|
||||
role: 'assistant',
|
||||
},
|
||||
{
|
||||
content: [
|
||||
{
|
||||
content: [
|
||||
{ type: 'text', text: 'Screenshot captured' },
|
||||
{
|
||||
type: 'image',
|
||||
source: {
|
||||
type: 'base64',
|
||||
media_type: 'image/png',
|
||||
data: 'screenshotBase64Data',
|
||||
},
|
||||
},
|
||||
],
|
||||
tool_use_id: 'toolu_screenshot_123',
|
||||
type: 'tool_result',
|
||||
},
|
||||
],
|
||||
role: 'user',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle orphan tool message with null content', async () => {
|
||||
// Tool message without corresponding assistant tool_call
|
||||
const messages: OpenAIChatMessage[] = [
|
||||
{
|
||||
content: null as any,
|
||||
name: 'some_tool',
|
||||
role: 'tool',
|
||||
tool_call_id: 'orphan_tool_call_id',
|
||||
},
|
||||
{
|
||||
content: 'Continue',
|
||||
role: 'user',
|
||||
},
|
||||
];
|
||||
|
||||
const contents = await buildAnthropicMessages(messages);
|
||||
|
||||
expect(contents).toEqual([
|
||||
{
|
||||
content: '<empty_content>',
|
||||
role: 'user',
|
||||
},
|
||||
{
|
||||
content: 'Continue',
|
||||
role: 'user',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle orphan tool message with empty string content', async () => {
|
||||
// Tool message without corresponding assistant tool_call
|
||||
const messages: OpenAIChatMessage[] = [
|
||||
{
|
||||
content: '',
|
||||
name: 'some_tool',
|
||||
role: 'tool',
|
||||
tool_call_id: 'orphan_tool_call_id',
|
||||
},
|
||||
{
|
||||
content: 'Continue',
|
||||
role: 'user',
|
||||
},
|
||||
];
|
||||
|
||||
const contents = await buildAnthropicMessages(messages);
|
||||
|
||||
expect(contents).toEqual([
|
||||
{
|
||||
content: '<empty_content>',
|
||||
role: 'user',
|
||||
},
|
||||
{
|
||||
content: 'Continue',
|
||||
role: 'user',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should work well starting with tool message', async () => {
|
||||
const messages: OpenAIChatMessage[] = [
|
||||
{
|
||||
|
||||
@@ -114,10 +114,13 @@ export const buildAnthropicMessage = async (
|
||||
// if there is tool_calls , we need to covert the tool_calls to tool_use content block
|
||||
// refs: https://docs.anthropic.com/claude/docs/tool-use#tool-use-and-tool-result-content-blocks
|
||||
if (message.tool_calls && message.tool_calls.length > 0) {
|
||||
// Handle content: string with text, array, null/undefined/empty -> filter out
|
||||
const rawContent =
|
||||
typeof content === 'string'
|
||||
? ([{ text: message.content, type: 'text' }] as UserMessageContentPart[])
|
||||
: content;
|
||||
typeof content === 'string' && content.trim()
|
||||
? ([{ text: content, type: 'text' }] as UserMessageContentPart[])
|
||||
: Array.isArray(content)
|
||||
? content
|
||||
: []; // null/undefined/empty string -> empty array (will be filtered)
|
||||
|
||||
const messageContent = await buildArrayContent(rawContent);
|
||||
|
||||
@@ -180,10 +183,17 @@ export const buildAnthropicMessages = async (
|
||||
|
||||
// refs: https://docs.anthropic.com/claude/docs/tool-use#tool-use-and-tool-result-content-blocks
|
||||
if (message.role === 'tool') {
|
||||
// Handle different content types in tool messages
|
||||
const toolResultContent = Array.isArray(message.content)
|
||||
? await buildArrayContent(message.content)
|
||||
: !message.content
|
||||
? [{ text: '<empty_content>', type: 'text' as const }]
|
||||
: [{ text: message.content, type: 'text' as const }];
|
||||
|
||||
// 检查这个工具消息是否有对应的 assistant 工具调用
|
||||
if (message.tool_call_id && validToolCallIds.has(message.tool_call_id)) {
|
||||
pendingToolResults.push({
|
||||
content: [{ text: message.content as string, type: 'text' }],
|
||||
content: toolResultContent as Anthropic.ToolResultBlockParam['content'],
|
||||
tool_use_id: message.tool_call_id,
|
||||
type: 'tool_result',
|
||||
});
|
||||
@@ -198,8 +208,11 @@ export const buildAnthropicMessages = async (
|
||||
}
|
||||
} else {
|
||||
// 如果工具消息没有对应的 assistant 工具调用,则作为普通文本处理
|
||||
const fallbackContent = Array.isArray(message.content)
|
||||
? JSON.stringify(message.content)
|
||||
: message.content || '<empty_content>';
|
||||
messages.push({
|
||||
content: message.content as string,
|
||||
content: fallbackContent,
|
||||
role: 'user',
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user