Skip to content

Commit e2515c3

Browse files
authored
fix: Anthropic request errors when using parallel tool calls (#16359)
The cache marker algorithm in the Anthropic language model was incorrectly removing content blocks, causing invalid requests when using parallel tool calls or other multi-content-block scenarios. Update the algorithm to preserve all content blocks while applying cache markers, ensuring message integrity for complex requests. Fixes #16355
1 parent 6ce5dee commit e2515c3

File tree

2 files changed

+108
-12
lines changed

2 files changed

+108
-12
lines changed

packages/ai-anthropic/src/node/anthropic-language-model.spec.ts

Lines changed: 90 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
// *****************************************************************************
1616

1717
import { expect } from 'chai';
18-
import { AnthropicModel, DEFAULT_MAX_TOKENS } from './anthropic-language-model';
18+
import { AnthropicModel, DEFAULT_MAX_TOKENS, addCacheControlToLastMessage } from './anthropic-language-model';
1919

2020
describe('AnthropicModel', () => {
2121

@@ -75,4 +75,93 @@ describe('AnthropicModel', () => {
7575
expect(model.maxRetries).to.equal(5);
7676
});
7777
});
78+
79+
describe('addCacheControlToLastMessage', () => {
80+
it('should preserve all content blocks when adding cache control to parallel tool calls', () => {
81+
const messages = [
82+
{
83+
role: 'user' as const,
84+
content: [
85+
{ type: 'tool_result' as const, tool_use_id: 'tool1', content: 'result1' },
86+
{ type: 'tool_result' as const, tool_use_id: 'tool2', content: 'result2' },
87+
{ type: 'tool_result' as const, tool_use_id: 'tool3', content: 'result3' }
88+
]
89+
}
90+
];
91+
92+
const result = addCacheControlToLastMessage(messages);
93+
94+
expect(result).to.have.lengthOf(1);
95+
expect(result[0].content).to.be.an('array').with.lengthOf(3);
96+
expect(result[0].content[0]).to.deep.equal({ type: 'tool_result', tool_use_id: 'tool1', content: 'result1' });
97+
expect(result[0].content[1]).to.deep.equal({ type: 'tool_result', tool_use_id: 'tool2', content: 'result2' });
98+
expect(result[0].content[2]).to.deep.equal({
99+
type: 'tool_result',
100+
tool_use_id: 'tool3',
101+
content: 'result3',
102+
cache_control: { type: 'ephemeral' }
103+
});
104+
});
105+
106+
it('should add cache control to last non-thinking block in mixed content', () => {
107+
const messages = [
108+
{
109+
role: 'assistant' as const,
110+
content: [
111+
{ type: 'text' as const, text: 'Some text' },
112+
{ type: 'tool_use' as const, id: 'tool1', name: 'getTool', input: {} },
113+
{ type: 'thinking' as const, thinking: 'thinking content', signature: 'signature' }
114+
]
115+
}
116+
];
117+
118+
const result = addCacheControlToLastMessage(messages);
119+
120+
expect(result).to.have.lengthOf(1);
121+
expect(result[0].content).to.be.an('array').with.lengthOf(3);
122+
expect(result[0].content[0]).to.deep.equal({ type: 'text', text: 'Some text' });
123+
expect(result[0].content[1]).to.deep.equal({
124+
type: 'tool_use',
125+
id: 'tool1',
126+
name: 'getTool',
127+
input: {},
128+
cache_control: { type: 'ephemeral' }
129+
});
130+
expect(result[0].content[2]).to.deep.equal({ type: 'thinking', thinking: 'thinking content', signature: 'signature' });
131+
});
132+
133+
it('should handle string content by converting to content block', () => {
134+
const messages = [
135+
{
136+
role: 'user' as const,
137+
content: 'Simple text message'
138+
}
139+
];
140+
141+
const result = addCacheControlToLastMessage(messages);
142+
143+
expect(result).to.have.lengthOf(1);
144+
expect(result[0].content).to.be.an('array').with.lengthOf(1);
145+
expect(result[0].content[0]).to.deep.equal({
146+
type: 'text',
147+
text: 'Simple text message',
148+
cache_control: { type: 'ephemeral' }
149+
});
150+
});
151+
152+
it('should not modify original messages', () => {
153+
const originalMessages = [
154+
{
155+
role: 'user' as const,
156+
content: [
157+
{ type: 'tool_result' as const, tool_use_id: 'tool1', content: 'result1' }
158+
]
159+
}
160+
];
161+
162+
addCacheControlToLastMessage(originalMessages);
163+
164+
expect(originalMessages[0].content[0]).to.not.have.property('cache_control');
165+
});
166+
});
78167
});

packages/ai-anthropic/src/node/anthropic-language-model.ts

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -119,24 +119,31 @@ function transformToAnthropicParams(
119119
* @returns A new messages array with the last message adapted to include cache control. If no cache control can be added, the original messages are returned.
120120
* In any case, the original messages are not modified
121121
*/
122-
function addCacheControlToLastMessage(messages: Anthropic.Messages.MessageParam[]): Anthropic.Messages.MessageParam[] {
122+
export function addCacheControlToLastMessage(messages: Anthropic.Messages.MessageParam[]): Anthropic.Messages.MessageParam[] {
123123
const clonedMessages = [...messages];
124124
const latestMessage = clonedMessages.pop();
125125
if (latestMessage) {
126-
let content: NonThinkingParam | undefined = undefined;
127126
if (typeof latestMessage.content === 'string') {
128-
content = { type: 'text', text: latestMessage.content };
127+
// Wrap the string content into a content block with cache control
128+
const cachedContent: NonThinkingParam = {
129+
type: 'text',
130+
text: latestMessage.content,
131+
cache_control: { type: 'ephemeral' }
132+
};
133+
return [...clonedMessages, { ...latestMessage, content: [cachedContent] }];
129134
} else if (Array.isArray(latestMessage.content)) {
130-
// we can't set cache control on thinking messages, so we only set it on the last non-thinking block
131-
const filteredContent = latestMessage.content.filter(isNonThinkingParam);
132-
if (filteredContent.length) {
133-
content = filteredContent[filteredContent.length - 1];
135+
// Update the last non-thinking content block to include cache control
136+
const updatedContent = [...latestMessage.content];
137+
for (let i = updatedContent.length - 1; i >= 0; i--) {
138+
if (isNonThinkingParam(updatedContent[i])) {
139+
updatedContent[i] = {
140+
...updatedContent[i],
141+
cache_control: { type: 'ephemeral' }
142+
} as NonThinkingParam;
143+
return [...clonedMessages, { ...latestMessage, content: updatedContent }];
144+
}
134145
}
135146
}
136-
if (content) {
137-
const cachedContent: NonThinkingParam = { ...content, cache_control: { type: 'ephemeral' } };
138-
return [...clonedMessages, { ...latestMessage, content: [cachedContent] }];
139-
}
140147
}
141148
return messages;
142149
}

0 commit comments

Comments
 (0)