Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 40 additions & 12 deletions api/app/clients/AnthropicClient.js
Original file line number Diff line number Diff line change
Expand Up @@ -487,23 +487,51 @@ class AnthropicClient extends BaseClient {

groupedMessages = groupedMessages.map((msg, i) => {
const isLast = i === groupedMessages.length - 1;
if (msg.content.length === 1) {
const content = msg.content[0];
return {
...msg,
// reason: final assistant content cannot end with trailing whitespace
content:
isLast && this.useMessages && msg.role === 'assistant' && typeof content === 'string'
? content?.trim()
: content,
};

// Always flatten the content array, regardless of length
let content = msg.content.length === 1 ? msg.content[0] : msg.content.flat();

// Ensure all content elements are properly formatted objects
if (Array.isArray(content)) {
content = content.map((item) => {
if (typeof item === 'string') {
return { type: 'text', text: item };
}
return item;
});
}

// Helper function to trim trailing whitespace from final assistant messages
const trimFinalAssistantContent = (content) => {
if (!isLast || !this.useMessages || msg.role !== 'assistant') {
return content;
}

if (typeof content === 'string') {
return content.trim();
}

if (Array.isArray(content)) {
// Find and trim the last text element
for (let j = content.length - 1; j >= 0; j--) {
if (content[j]?.type === 'text' && typeof content[j].text === 'string') {
content[j] = { ...content[j], text: content[j].text.trim() };
break;
}
}
}

return content;
};

if (!this.useMessages && msg.tokenCount) {
delete msg.tokenCount;
}

return msg;
return {
...msg,
content: trimFinalAssistantContent(content),
};
});

let identityPrefix = '';
Expand Down Expand Up @@ -941,7 +969,7 @@ class AnthropicClient extends BaseClient {
const content = `<conversation_context>
${convo}
</conversation_context>

Please generate a title for this conversation.`;

const titleMessage = { role: 'user', content };
Expand Down
160 changes: 160 additions & 0 deletions api/app/clients/specs/AnthropicClient.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,165 @@ describe('AnthropicClient', () => {
expect(prompt).toContain("Human's name: John");
expect(prompt).toContain('You are Claude-2');
});

it('should flatten multi-element content arrays correctly', async () => {
// Set up the client for Messages API (Claude 3+)
client.useMessages = true;
client.options.modelOptions.model = 'claude-3-opus-20240229';

const messagesWithMultiContent = [
{
role: 'user',
content: [
{
type: 'image',
source: { type: 'base64', media_type: 'image/png', data: 'fake-data' },
},
{ type: 'text', text: 'describe this image' },
],
messageId: '1',
},
{
role: 'user',
content: 'follow up question',
messageId: '2',
parentMessageId: '1',
},
];

const result = await client.buildMessages(messagesWithMultiContent, '2');

// Should have one grouped message with flattened content
expect(result.context).toHaveLength(1);
const groupedMessage = result.context[0];

// Content should be flattened array with all elements
expect(Array.isArray(groupedMessage.content)).toBe(true);
expect(groupedMessage.content).toHaveLength(3);

// Check that all elements are properly formatted objects
expect(groupedMessage.content[0]).toEqual({
type: 'image',
source: { type: 'base64', media_type: 'image/png', data: 'fake-data' },
});
expect(groupedMessage.content[1]).toEqual({
type: 'text',
text: 'describe this image',
});
expect(groupedMessage.content[2]).toEqual({
type: 'text',
text: 'follow up question',
});
});

it('should convert raw strings to proper text objects during flattening', async () => {
client.useMessages = true;
client.options.modelOptions.model = 'claude-3-opus-20240229';

const messagesWithStringContent = [
{
role: 'user',
content: [{ type: 'text', text: 'first message' }],
messageId: '1',
},
{
role: 'user',
content: 'raw string message', // This should become an object in the content array
messageId: '2',
parentMessageId: '1',
},
];

const result = await client.buildMessages(messagesWithStringContent, '2');

expect(result.context).toHaveLength(1);
const groupedMessage = result.context[0];

// Both content elements should be properly formatted objects
expect(groupedMessage.content).toHaveLength(2);
expect(groupedMessage.content[0]).toEqual({
type: 'text',
text: 'first message',
});
expect(groupedMessage.content[1]).toEqual({
type: 'text',
text: 'raw string message',
});
});

it('should trim trailing whitespace from final assistant messages with flattened content', async () => {
client.useMessages = true;
client.options.modelOptions.model = 'claude-3-opus-20240229';

const messagesWithTrailingWhitespace = [
{
role: 'user',
content: 'hello',
messageId: '1',
},
{
role: 'assistant',
content: [{ type: 'text', text: 'first response' }],
messageId: '2',
parentMessageId: '1',
},
{
role: 'assistant',
content: 'second response with whitespace \n ', // Raw string with trailing whitespace
messageId: '3',
parentMessageId: '2',
},
];

const result = await client.buildMessages(messagesWithTrailingWhitespace, '3');

// Should have 2 messages: user and grouped assistant
expect(result.context).toHaveLength(2);

const finalAssistantMessage = result.context[1];
expect(finalAssistantMessage.role).toBe('assistant');

// Should have flattened content with a trimmed final text element
expect(Array.isArray(finalAssistantMessage.content)).toBe(true);
expect(finalAssistantMessage.content).toHaveLength(2);

expect(finalAssistantMessage.content[0]).toEqual({
type: 'text',
text: 'first response',
});
expect(finalAssistantMessage.content[1]).toEqual({
type: 'text',
text: 'second response with whitespace', // Trimmed
});
});

it('should trim single string content from final assistant messages', async () => {
client.useMessages = true;
client.options.modelOptions.model = 'claude-3-opus-20240229';

const messagesWithStringAssistant = [
{
role: 'user',
content: 'hello',
messageId: '1',
},
{
role: 'assistant',
content: 'response with trailing whitespace \n ',
messageId: '2',
parentMessageId: '1',
},
];

const result = await client.buildMessages(messagesWithStringAssistant, '2');

expect(result.context).toHaveLength(2);
const assistantMessage = result.context[1];

// Should be trimmed string, not array
expect(typeof assistantMessage.content).toBe('string');
expect(assistantMessage.content).toBe('response with trailing whitespace');
});
});

describe('getClient', () => {
Expand Down Expand Up @@ -921,6 +1080,7 @@ describe('AnthropicClient', () => {
describe('configureReasoning', () => {
it('should enable thinking for claude-opus-4 and claude-sonnet-4 models', async () => {
const client = new AnthropicClient('test-api-key');

// Create a mock async generator function
async function* mockAsyncGenerator() {
yield { type: 'message_start', message: { usage: {} } };
Expand Down