@librechat/agents 3.2.35 → 3.2.37
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/agents/AgentContext.cjs +75 -2
- package/dist/cjs/agents/AgentContext.cjs.map +1 -1
- package/dist/cjs/agents/projection.cjs +25 -0
- package/dist/cjs/agents/projection.cjs.map +1 -0
- package/dist/cjs/graphs/Graph.cjs +10 -26
- package/dist/cjs/graphs/Graph.cjs.map +1 -1
- package/dist/cjs/langfuse.cjs +16 -5
- package/dist/cjs/langfuse.cjs.map +1 -1
- package/dist/cjs/langfuseToolOutputTracing.cjs +7 -0
- package/dist/cjs/langfuseToolOutputTracing.cjs.map +1 -1
- package/dist/cjs/llm/anthropic/utils/message_inputs.cjs +118 -7
- package/dist/cjs/llm/anthropic/utils/message_inputs.cjs.map +1 -1
- package/dist/cjs/llm/bedrock/utils/message_inputs.cjs +44 -4
- package/dist/cjs/llm/bedrock/utils/message_inputs.cjs.map +1 -1
- package/dist/cjs/main.cjs +7 -0
- package/dist/cjs/messages/budget.cjs +23 -0
- package/dist/cjs/messages/budget.cjs.map +1 -0
- package/dist/cjs/messages/cache.cjs +184 -0
- package/dist/cjs/messages/cache.cjs.map +1 -1
- package/dist/cjs/messages/index.cjs +1 -0
- package/dist/cjs/summarization/node.cjs +1 -1
- package/dist/cjs/summarization/node.cjs.map +1 -1
- package/dist/cjs/tools/search/format.cjs +91 -2
- package/dist/cjs/tools/search/format.cjs.map +1 -1
- package/dist/cjs/tools/search/tool.cjs +4 -3
- package/dist/cjs/tools/search/tool.cjs.map +1 -1
- package/dist/cjs/tools/toolOutputReferences.cjs +28 -14
- package/dist/cjs/tools/toolOutputReferences.cjs.map +1 -1
- package/dist/esm/agents/AgentContext.mjs +76 -3
- package/dist/esm/agents/AgentContext.mjs.map +1 -1
- package/dist/esm/agents/projection.mjs +25 -0
- package/dist/esm/agents/projection.mjs.map +1 -0
- package/dist/esm/graphs/Graph.mjs +9 -25
- package/dist/esm/graphs/Graph.mjs.map +1 -1
- package/dist/esm/langfuse.mjs +16 -5
- package/dist/esm/langfuse.mjs.map +1 -1
- package/dist/esm/langfuseToolOutputTracing.mjs +7 -0
- package/dist/esm/langfuseToolOutputTracing.mjs.map +1 -1
- package/dist/esm/llm/anthropic/utils/message_inputs.mjs +118 -7
- package/dist/esm/llm/anthropic/utils/message_inputs.mjs.map +1 -1
- package/dist/esm/llm/bedrock/utils/message_inputs.mjs +44 -4
- package/dist/esm/llm/bedrock/utils/message_inputs.mjs.map +1 -1
- package/dist/esm/main.mjs +4 -2
- package/dist/esm/messages/budget.mjs +23 -0
- package/dist/esm/messages/budget.mjs.map +1 -0
- package/dist/esm/messages/cache.mjs +182 -1
- package/dist/esm/messages/cache.mjs.map +1 -1
- package/dist/esm/messages/index.mjs +1 -0
- package/dist/esm/summarization/node.mjs +2 -2
- package/dist/esm/summarization/node.mjs.map +1 -1
- package/dist/esm/tools/search/format.mjs +91 -2
- package/dist/esm/tools/search/format.mjs.map +1 -1
- package/dist/esm/tools/search/tool.mjs +4 -3
- package/dist/esm/tools/search/tool.mjs.map +1 -1
- package/dist/esm/tools/toolOutputReferences.mjs +28 -14
- package/dist/esm/tools/toolOutputReferences.mjs.map +1 -1
- package/dist/types/agents/AgentContext.d.ts +30 -1
- package/dist/types/agents/projection.d.ts +26 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/messages/budget.d.ts +11 -0
- package/dist/types/messages/cache.d.ts +47 -0
- package/dist/types/messages/index.d.ts +1 -0
- package/dist/types/tools/search/format.d.ts +4 -1
- package/dist/types/tools/search/types.d.ts +7 -0
- package/dist/types/types/graph.d.ts +2 -0
- package/package.json +2 -1
- package/src/agents/AgentContext.ts +105 -4
- package/src/agents/__tests__/AgentContext.test.ts +232 -9
- package/src/agents/__tests__/projection.test.ts +73 -0
- package/src/agents/projection.ts +46 -0
- package/src/graphs/Graph.ts +66 -65
- package/src/index.ts +3 -0
- package/src/langfuse.ts +38 -4
- package/src/langfuseToolOutputTracing.ts +18 -0
- package/src/llm/anthropic/utils/cross-provider-reasoning.test.ts +317 -0
- package/src/llm/anthropic/utils/message_inputs.ts +209 -19
- package/src/llm/anthropic/utils/stripPrefillCache.test.ts +111 -0
- package/src/llm/bedrock/utils/cross-provider-reasoning.test.ts +131 -0
- package/src/llm/bedrock/utils/message_inputs.test.ts +129 -0
- package/src/llm/bedrock/utils/message_inputs.ts +81 -4
- package/src/llm/bedrock/utils/toolResultCachePoint.test.ts +103 -0
- package/src/messages/budget.ts +32 -0
- package/src/messages/cache.tail.test.ts +340 -0
- package/src/messages/cache.ts +267 -1
- package/src/messages/index.ts +1 -0
- package/src/messages/tailCacheConversion.test.ts +161 -0
- package/src/scripts/bench-prompt-cache.ts +479 -0
- package/src/specs/langfuse-config.test.ts +69 -2
- package/src/specs/langfuse-metadata.test.ts +44 -0
- package/src/specs/langfuse-tool-output-tracing.test.ts +6 -0
- package/src/summarization/node.ts +2 -2
- package/src/tools/__tests__/annotateMessagesForLLM.test.ts +50 -0
- package/src/tools/search/format.test.ts +242 -0
- package/src/tools/search/format.ts +122 -5
- package/src/tools/search/tool.ts +5 -1
- package/src/tools/search/types.ts +7 -0
- package/src/tools/toolOutputReferences.ts +34 -20
- package/src/types/graph.ts +2 -0
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
import { AIMessage, HumanMessage } from '@langchain/core/messages';
|
|
2
|
+
import type { BaseMessage } from '@langchain/core/messages';
|
|
3
|
+
import { _convertMessagesToAnthropicPayload } from './message_inputs';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Regression for cross-provider agent handoffs (e.g. Bedrock → Anthropic): a
|
|
7
|
+
* Bedrock turn that used extended thinking leaves a `reasoning_content` content
|
|
8
|
+
* block ({ reasoningText: { text, signature } }) in the history. The official
|
|
9
|
+
* Anthropic converter has no branch for it and previously threw
|
|
10
|
+
* "Unsupported message content format", crashing the handoff. Only known
|
|
11
|
+
* foreign reasoning (Bedrock `reasoning_content`, Google `reasoning`, LibreChat
|
|
12
|
+
* `think`) is dropped; any other unknown block still throws rather than being
|
|
13
|
+
* silently omitted (real content — user media, Google code-execution — must be
|
|
14
|
+
* surfaced); and a tool call carried only on `tool_calls` survives dropping its
|
|
15
|
+
* reasoning sibling without being duplicated.
|
|
16
|
+
*/
|
|
17
|
+
type AnthropicPayload = ReturnType<typeof _convertMessagesToAnthropicPayload>;
|
|
18
|
+
|
|
19
|
+
/** Minimal view of a converted Anthropic content block the assertions read. */
|
|
20
|
+
interface TestBlock {
|
|
21
|
+
type?: string;
|
|
22
|
+
text?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const findAssistant = (payload: AnthropicPayload) =>
|
|
26
|
+
payload.messages.find((m) => m.role === 'assistant');
|
|
27
|
+
|
|
28
|
+
const assistantBlocks = (payload: AnthropicPayload): TestBlock[] => {
|
|
29
|
+
const content = findAssistant(payload)?.content;
|
|
30
|
+
return Array.isArray(content) ? (content as TestBlock[]) : [];
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
describe('_convertMessagesToAnthropicPayload — cross-provider reasoning blocks', () => {
|
|
34
|
+
const bedrockHandoffHistory = (): BaseMessage[] => [
|
|
35
|
+
new HumanMessage('research Assort Health'),
|
|
36
|
+
new AIMessage({
|
|
37
|
+
content: [
|
|
38
|
+
{
|
|
39
|
+
type: 'reasoning_content',
|
|
40
|
+
index: 0,
|
|
41
|
+
reasoningText: {
|
|
42
|
+
text: 'Let me search Notion then hand off to the data agent.',
|
|
43
|
+
signature: 'bedrock-signature-not-valid-for-anthropic',
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
{ type: 'text', text: 'Kicking off the searches now.' },
|
|
47
|
+
{
|
|
48
|
+
type: 'tool_use',
|
|
49
|
+
id: 'tooluse_abc',
|
|
50
|
+
name: 'notion-search',
|
|
51
|
+
input: { query: 'Assort Health' },
|
|
52
|
+
},
|
|
53
|
+
],
|
|
54
|
+
tool_calls: [
|
|
55
|
+
{
|
|
56
|
+
id: 'tooluse_abc',
|
|
57
|
+
name: 'notion-search',
|
|
58
|
+
args: { query: 'Assort Health' },
|
|
59
|
+
type: 'tool_call',
|
|
60
|
+
},
|
|
61
|
+
],
|
|
62
|
+
}),
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
it('does not throw on a Bedrock reasoning_content block', () => {
|
|
66
|
+
expect(() =>
|
|
67
|
+
_convertMessagesToAnthropicPayload(bedrockHandoffHistory())
|
|
68
|
+
).not.toThrow();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('drops reasoning_content (incl. its foreign signature) but keeps text and tool_use', () => {
|
|
72
|
+
const payload = _convertMessagesToAnthropicPayload(bedrockHandoffHistory());
|
|
73
|
+
expect(findAssistant(payload)).toBeDefined();
|
|
74
|
+
const blocks = assistantBlocks(payload);
|
|
75
|
+
|
|
76
|
+
expect(blocks.find((b) => b.type === 'reasoning_content')).toBeUndefined();
|
|
77
|
+
expect(
|
|
78
|
+
blocks.find(
|
|
79
|
+
(b) => b.type === 'thinking' || b.type === 'redacted_thinking'
|
|
80
|
+
)
|
|
81
|
+
).toBeUndefined();
|
|
82
|
+
expect(JSON.stringify(blocks)).not.toContain(
|
|
83
|
+
'bedrock-signature-not-valid-for-anthropic'
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
expect(
|
|
87
|
+
blocks.some(
|
|
88
|
+
(b) => b.type === 'text' && b.text === 'Kicking off the searches now.'
|
|
89
|
+
)
|
|
90
|
+
).toBe(true);
|
|
91
|
+
expect(blocks.find((b) => b.type === 'tool_use')).toMatchObject({
|
|
92
|
+
type: 'tool_use',
|
|
93
|
+
id: 'tooluse_abc',
|
|
94
|
+
name: 'notion-search',
|
|
95
|
+
input: { query: 'Assort Health' },
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('drops a Google `reasoning` block without throwing', () => {
|
|
100
|
+
const history: BaseMessage[] = [
|
|
101
|
+
new HumanMessage('hi'),
|
|
102
|
+
new AIMessage({
|
|
103
|
+
content: [
|
|
104
|
+
{ type: 'reasoning', reasoning: 'internal google chain of thought' },
|
|
105
|
+
{ type: 'text', text: 'Hello!' },
|
|
106
|
+
],
|
|
107
|
+
}),
|
|
108
|
+
];
|
|
109
|
+
expect(() => _convertMessagesToAnthropicPayload(history)).not.toThrow();
|
|
110
|
+
const blocks = assistantBlocks(_convertMessagesToAnthropicPayload(history));
|
|
111
|
+
expect(blocks.find((b) => b.type === 'reasoning')).toBeUndefined();
|
|
112
|
+
expect(blocks.some((b) => b.type === 'text' && b.text === 'Hello!')).toBe(
|
|
113
|
+
true
|
|
114
|
+
);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('drops a LibreChat `think` block without throwing', () => {
|
|
118
|
+
const history: BaseMessage[] = [
|
|
119
|
+
new HumanMessage('hi'),
|
|
120
|
+
new AIMessage({
|
|
121
|
+
content: [
|
|
122
|
+
{ type: 'think', think: 'librechat serialized reasoning' },
|
|
123
|
+
{ type: 'text', text: 'Done.' },
|
|
124
|
+
],
|
|
125
|
+
}),
|
|
126
|
+
];
|
|
127
|
+
expect(() => _convertMessagesToAnthropicPayload(history)).not.toThrow();
|
|
128
|
+
const blocks = assistantBlocks(_convertMessagesToAnthropicPayload(history));
|
|
129
|
+
expect(blocks.find((b) => b.type === 'think')).toBeUndefined();
|
|
130
|
+
expect(blocks.some((b) => b.type === 'text' && b.text === 'Done.')).toBe(
|
|
131
|
+
true
|
|
132
|
+
);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('drops an unsigned `thinking` block (Google thinking-enabled output) on an assistant turn', () => {
|
|
136
|
+
const history: BaseMessage[] = [
|
|
137
|
+
new HumanMessage('hi'),
|
|
138
|
+
new AIMessage({
|
|
139
|
+
content: [
|
|
140
|
+
{
|
|
141
|
+
type: 'thinking',
|
|
142
|
+
thinking: 'google chain of thought, no signature',
|
|
143
|
+
},
|
|
144
|
+
{ type: 'text', text: 'Answer.' },
|
|
145
|
+
],
|
|
146
|
+
}),
|
|
147
|
+
];
|
|
148
|
+
expect(() => _convertMessagesToAnthropicPayload(history)).not.toThrow();
|
|
149
|
+
const blocks = assistantBlocks(_convertMessagesToAnthropicPayload(history));
|
|
150
|
+
expect(blocks.find((b) => b.type === 'thinking')).toBeUndefined();
|
|
151
|
+
expect(blocks.some((b) => b.type === 'text' && b.text === 'Answer.')).toBe(
|
|
152
|
+
true
|
|
153
|
+
);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('forwards a signed `thinking` block (Anthropic-native) unchanged', () => {
|
|
157
|
+
const history: BaseMessage[] = [
|
|
158
|
+
new HumanMessage('hi'),
|
|
159
|
+
new AIMessage({
|
|
160
|
+
content: [
|
|
161
|
+
{
|
|
162
|
+
type: 'thinking',
|
|
163
|
+
thinking: 'native reasoning',
|
|
164
|
+
signature: 'valid-sig',
|
|
165
|
+
},
|
|
166
|
+
{ type: 'text', text: 'Answer.' },
|
|
167
|
+
],
|
|
168
|
+
}),
|
|
169
|
+
];
|
|
170
|
+
const blocks = assistantBlocks(_convertMessagesToAnthropicPayload(history));
|
|
171
|
+
expect(blocks.find((b) => b.type === 'thinking')).toMatchObject({
|
|
172
|
+
type: 'thinking',
|
|
173
|
+
thinking: 'native reasoning',
|
|
174
|
+
signature: 'valid-sig',
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('throws (not silently drops) on an unknown assistant block such as Google code execution', () => {
|
|
179
|
+
// executableCode/codeExecutionResult carry real visible content; silently
|
|
180
|
+
// dropping them on a Google → Anthropic handoff would lose evidence.
|
|
181
|
+
const history: BaseMessage[] = [
|
|
182
|
+
new HumanMessage('run some code'),
|
|
183
|
+
new AIMessage({
|
|
184
|
+
content: [
|
|
185
|
+
{
|
|
186
|
+
type: 'executableCode',
|
|
187
|
+
executableCode: { language: 'PYTHON', code: 'print(2+2)' },
|
|
188
|
+
},
|
|
189
|
+
{ type: 'text', text: 'Here is the result.' },
|
|
190
|
+
],
|
|
191
|
+
}),
|
|
192
|
+
];
|
|
193
|
+
expect(() => _convertMessagesToAnthropicPayload(history)).toThrow(
|
|
194
|
+
'Unsupported message content format'
|
|
195
|
+
);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('throws (not silently drops) on an unsupported user block such as media', () => {
|
|
199
|
+
const history: BaseMessage[] = [
|
|
200
|
+
new HumanMessage({
|
|
201
|
+
content: [
|
|
202
|
+
{
|
|
203
|
+
type: 'video_url',
|
|
204
|
+
video_url: { url: 'https://example.com/v.mp4' },
|
|
205
|
+
},
|
|
206
|
+
{ type: 'text', text: 'what is in this video?' },
|
|
207
|
+
],
|
|
208
|
+
}),
|
|
209
|
+
];
|
|
210
|
+
expect(() => _convertMessagesToAnthropicPayload(history)).toThrow(
|
|
211
|
+
'Unsupported message content format'
|
|
212
|
+
);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('does not drop a reasoning-typed block on a user turn (only assistant reasoning is dropped)', () => {
|
|
216
|
+
const history: BaseMessage[] = [
|
|
217
|
+
new HumanMessage({
|
|
218
|
+
content: [
|
|
219
|
+
{ type: 'reasoning_content', reasoningText: { text: 'user text' } },
|
|
220
|
+
{ type: 'text', text: 'hello' },
|
|
221
|
+
],
|
|
222
|
+
}),
|
|
223
|
+
];
|
|
224
|
+
expect(() => _convertMessagesToAnthropicPayload(history)).toThrow(
|
|
225
|
+
'Unsupported message content format'
|
|
226
|
+
);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('preserves a tool call carried only on tool_calls when its reasoning sibling is dropped', () => {
|
|
230
|
+
// Mirrors a Bedrock extended-thinking turn: the tool lives only on
|
|
231
|
+
// `tool_calls`; `content` holds just the reasoning block (no tool_use).
|
|
232
|
+
const history: BaseMessage[] = [
|
|
233
|
+
new HumanMessage('research Assort Health'),
|
|
234
|
+
new AIMessage({
|
|
235
|
+
content: [
|
|
236
|
+
{
|
|
237
|
+
type: 'reasoning_content',
|
|
238
|
+
reasoningText: { text: 'I should hand off now.', signature: 'sig' },
|
|
239
|
+
},
|
|
240
|
+
],
|
|
241
|
+
tool_calls: [
|
|
242
|
+
{
|
|
243
|
+
id: 'tooluse_transfer',
|
|
244
|
+
name: 'lc_transfer_to_data_agent',
|
|
245
|
+
args: { reason: 'need consumption data' },
|
|
246
|
+
type: 'tool_call',
|
|
247
|
+
},
|
|
248
|
+
],
|
|
249
|
+
}),
|
|
250
|
+
];
|
|
251
|
+
expect(() => _convertMessagesToAnthropicPayload(history)).not.toThrow();
|
|
252
|
+
const blocks = assistantBlocks(_convertMessagesToAnthropicPayload(history));
|
|
253
|
+
expect(blocks.find((b) => b.type === 'reasoning_content')).toBeUndefined();
|
|
254
|
+
expect(blocks.find((b) => b.type === 'tool_use')).toMatchObject({
|
|
255
|
+
type: 'tool_use',
|
|
256
|
+
id: 'tooluse_transfer',
|
|
257
|
+
name: 'lc_transfer_to_data_agent',
|
|
258
|
+
input: { reason: 'need consumption data' },
|
|
259
|
+
});
|
|
260
|
+
// The `_` placeholder must not linger once a real tool_use block is present.
|
|
261
|
+
expect(blocks.some((b) => b.type === 'text' && b.text === '_')).toBe(false);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it('does not duplicate a Google functionCall tool call already materialized by _formatContent', () => {
|
|
265
|
+
// _formatContent converts the `functionCall` part into a tool_use; the
|
|
266
|
+
// materialization must recognize it as represented and not append a second.
|
|
267
|
+
const history: BaseMessage[] = [
|
|
268
|
+
new HumanMessage('weather in SF?'),
|
|
269
|
+
new AIMessage({
|
|
270
|
+
content: [
|
|
271
|
+
{
|
|
272
|
+
type: 'functionCall',
|
|
273
|
+
functionCall: { name: 'get_weather', args: { city: 'SF' } },
|
|
274
|
+
},
|
|
275
|
+
],
|
|
276
|
+
tool_calls: [
|
|
277
|
+
{
|
|
278
|
+
id: 'call_weather_1',
|
|
279
|
+
name: 'get_weather',
|
|
280
|
+
args: { city: 'SF' },
|
|
281
|
+
type: 'tool_call',
|
|
282
|
+
},
|
|
283
|
+
],
|
|
284
|
+
}),
|
|
285
|
+
];
|
|
286
|
+
const blocks = assistantBlocks(_convertMessagesToAnthropicPayload(history));
|
|
287
|
+
const toolUses = blocks.filter((b) => b.type === 'tool_use');
|
|
288
|
+
expect(toolUses).toHaveLength(1);
|
|
289
|
+
expect(toolUses[0]).toMatchObject({
|
|
290
|
+
type: 'tool_use',
|
|
291
|
+
id: 'call_weather_1',
|
|
292
|
+
name: 'get_weather',
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it('falls back to placeholder text when reasoning was the only content', () => {
|
|
297
|
+
const history: BaseMessage[] = [
|
|
298
|
+
new HumanMessage('hi'),
|
|
299
|
+
new AIMessage({
|
|
300
|
+
content: [
|
|
301
|
+
{
|
|
302
|
+
type: 'reasoning_content',
|
|
303
|
+
reasoningText: {
|
|
304
|
+
text: 'only thinking, no visible text',
|
|
305
|
+
signature: 'sig',
|
|
306
|
+
},
|
|
307
|
+
},
|
|
308
|
+
],
|
|
309
|
+
}),
|
|
310
|
+
];
|
|
311
|
+
expect(() => _convertMessagesToAnthropicPayload(history)).not.toThrow();
|
|
312
|
+
const blocks = assistantBlocks(_convertMessagesToAnthropicPayload(history));
|
|
313
|
+
expect(blocks.find((b) => b.type === 'reasoning_content')).toBeUndefined();
|
|
314
|
+
expect(blocks.length).toBeGreaterThan(0);
|
|
315
|
+
expect(blocks.every((b) => b.type === 'text')).toBe(true);
|
|
316
|
+
});
|
|
317
|
+
});
|
|
@@ -140,6 +140,35 @@ export function normalizeAnthropicToolCallId(
|
|
|
140
140
|
return `${sanitized.slice(0, prefixMaxLength)}_${hash}`;
|
|
141
141
|
}
|
|
142
142
|
|
|
143
|
+
/**
|
|
144
|
+
* Lift any `cache_control` off the inner blocks of a tool result onto the
|
|
145
|
+
* `tool_result` block itself. Anthropic documents the top-level
|
|
146
|
+
* `messages.content` block as the cacheable position and does not document
|
|
147
|
+
* caching of sub-content blocks; the API currently honors a nested marker, but
|
|
148
|
+
* anchoring on the documented position keeps the single tail breakpoint robust
|
|
149
|
+
* (and mirrors the Bedrock cachePoint hoist). The first marker found wins; it is
|
|
150
|
+
* stripped from every inner block so exactly one survives, on the outer block.
|
|
151
|
+
*/
|
|
152
|
+
function hoistToolResultCacheControl(
|
|
153
|
+
content: string | MessageContentComplex[]
|
|
154
|
+
): { content: string | MessageContentComplex[]; cacheControl: unknown } {
|
|
155
|
+
if (!Array.isArray(content)) {
|
|
156
|
+
return { content, cacheControl: undefined };
|
|
157
|
+
}
|
|
158
|
+
let cacheControl: unknown;
|
|
159
|
+
const stripped = content.map((block) => {
|
|
160
|
+
if ('cache_control' in block) {
|
|
161
|
+
cacheControl ??= (block as Record<string, unknown>).cache_control;
|
|
162
|
+
const clone = { ...(block as Record<string, unknown>) };
|
|
163
|
+
delete clone.cache_control;
|
|
164
|
+
return clone as MessageContentComplex;
|
|
165
|
+
}
|
|
166
|
+
return block;
|
|
167
|
+
});
|
|
168
|
+
// `stripped` is element-equal to `content` when no marker was present.
|
|
169
|
+
return { content: stripped, cacheControl };
|
|
170
|
+
}
|
|
171
|
+
|
|
143
172
|
function _ensureMessageContents(
|
|
144
173
|
messages: BaseMessage[]
|
|
145
174
|
): (SystemMessage | HumanMessage | AIMessage)[] {
|
|
@@ -183,13 +212,20 @@ function _ensureMessageContents(
|
|
|
183
212
|
const toolMessageContent = (
|
|
184
213
|
message as { content?: BaseMessage['content'] | null }
|
|
185
214
|
).content;
|
|
215
|
+
// Hoist a tail cache_control off the inner content onto the
|
|
216
|
+
// tool_result block itself (the documented cacheable position).
|
|
217
|
+
const { content: hoistedContent, cacheControl } =
|
|
218
|
+
toolMessageContent != null
|
|
219
|
+
? hoistToolResultCacheControl(_formatContent(message))
|
|
220
|
+
: { content: undefined, cacheControl: undefined };
|
|
186
221
|
updatedMsgs.push(
|
|
187
222
|
new HumanMessage({
|
|
188
223
|
content: [
|
|
189
224
|
{
|
|
190
225
|
type: 'tool_result',
|
|
191
|
-
...(
|
|
192
|
-
|
|
226
|
+
...(hoistedContent != null ? { content: hoistedContent } : {}),
|
|
227
|
+
...(cacheControl != null
|
|
228
|
+
? { cache_control: cacheControl as { type: 'ephemeral' } }
|
|
193
229
|
: {}),
|
|
194
230
|
tool_use_id: normalizeAnthropicToolCallId(
|
|
195
231
|
(message as ToolMessage).tool_call_id
|
|
@@ -429,6 +465,14 @@ function _formatContent(message: BaseMessage) {
|
|
|
429
465
|
'web_search_result',
|
|
430
466
|
];
|
|
431
467
|
const textTypes = ['text', 'text_delta'];
|
|
468
|
+
/**
|
|
469
|
+
* Reasoning blocks emitted by other providers — Bedrock's `reasoning_content`,
|
|
470
|
+
* Google's `reasoning`, and LibreChat's `think`. Their signatures are
|
|
471
|
+
* provider-specific and cannot be validated by Anthropic, so on a
|
|
472
|
+
* cross-provider handoff (e.g. Bedrock → Anthropic) we drop them rather than
|
|
473
|
+
* forwarding an unusable block. The receiving model produces its own thinking.
|
|
474
|
+
*/
|
|
475
|
+
const foreignReasoningTypes = ['reasoning_content', 'reasoning', 'think'];
|
|
432
476
|
const { content } = message;
|
|
433
477
|
|
|
434
478
|
if (typeof content === 'string') {
|
|
@@ -568,6 +612,15 @@ function _formatContent(message: BaseMessage) {
|
|
|
568
612
|
};
|
|
569
613
|
} else if (contentPart.type === 'thinking') {
|
|
570
614
|
const thinkingPart = contentPart as AnthropicThinkingBlockParam;
|
|
615
|
+
// Google thinking-enabled output reuses `type: 'thinking'` but carries
|
|
616
|
+
// no Anthropic signature. Anthropic rejects an unsigned thinking block,
|
|
617
|
+
// so on an assistant turn treat it as foreign reasoning and drop it
|
|
618
|
+
// rather than forward an unusable block. Signed (Anthropic-native)
|
|
619
|
+
// thinking is forwarded as before.
|
|
620
|
+
const signature = (thinkingPart as { signature?: string }).signature;
|
|
621
|
+
if (isAIMessage(message) && (signature == null || signature === '')) {
|
|
622
|
+
return null;
|
|
623
|
+
}
|
|
571
624
|
const block: AnthropicThinkingBlockParam = {
|
|
572
625
|
type: 'thinking' as const, // Explicitly setting the type as "thinking"
|
|
573
626
|
thinking: thinkingPart.thinking,
|
|
@@ -651,7 +704,9 @@ function _formatContent(message: BaseMessage) {
|
|
|
651
704
|
(contentPartCopy.input === '' || contentPartCopy.input == null)
|
|
652
705
|
) {
|
|
653
706
|
const matchingToolCall = isAIMessage(message)
|
|
654
|
-
? message.tool_calls?.find(
|
|
707
|
+
? message.tool_calls?.find(
|
|
708
|
+
(toolCall) => toolCall.id === contentPartCopy.id
|
|
709
|
+
)
|
|
655
710
|
: undefined;
|
|
656
711
|
if (matchingToolCall) {
|
|
657
712
|
contentPartCopy.input = matchingToolCall.args;
|
|
@@ -666,7 +721,10 @@ function _formatContent(message: BaseMessage) {
|
|
|
666
721
|
typeof p.input === 'string'
|
|
667
722
|
);
|
|
668
723
|
})
|
|
669
|
-
.reduce(
|
|
724
|
+
.reduce(
|
|
725
|
+
(acc, part) => acc + (part as Record<string, unknown>).input,
|
|
726
|
+
''
|
|
727
|
+
);
|
|
670
728
|
if (merged !== '') {
|
|
671
729
|
contentPartCopy.input = merged;
|
|
672
730
|
}
|
|
@@ -720,6 +778,18 @@ function _formatContent(message: BaseMessage) {
|
|
|
720
778
|
name: correspondingToolCall.name,
|
|
721
779
|
input: functionCallPart.functionCall.args,
|
|
722
780
|
};
|
|
781
|
+
} else if (
|
|
782
|
+
isAIMessage(message) &&
|
|
783
|
+
foreignReasoningTypes.some((t) => t === contentPart.type)
|
|
784
|
+
) {
|
|
785
|
+
// Foreign reasoning on an ASSISTANT turn (Bedrock `reasoning_content`,
|
|
786
|
+
// Google `reasoning`, LibreChat `think`) carries provider-specific
|
|
787
|
+
// signatures Anthropic cannot validate; drop it so a cross-provider
|
|
788
|
+
// handoff doesn't crash. The same types on a user/tool turn are real
|
|
789
|
+
// input and fall through to the throw below rather than being silently
|
|
790
|
+
// dropped — as does any other unknown block (user media, Google
|
|
791
|
+
// code-execution), which must be surfaced, not discarded.
|
|
792
|
+
return null;
|
|
723
793
|
} else {
|
|
724
794
|
console.error(
|
|
725
795
|
'Unsupported content part:',
|
|
@@ -808,25 +878,53 @@ export function _convertMessagesToAnthropicPayload(
|
|
|
808
878
|
};
|
|
809
879
|
}
|
|
810
880
|
} else {
|
|
811
|
-
const
|
|
812
|
-
const
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
881
|
+
const formattedContent = _formatContent(message);
|
|
882
|
+
const formattedBlocks = Array.isArray(formattedContent)
|
|
883
|
+
? formattedContent
|
|
884
|
+
: [];
|
|
885
|
+
// Tool calls already materialized as content blocks by `_formatContent`.
|
|
886
|
+
// Derived from the FORMATTED output (not the raw content by type) so
|
|
887
|
+
// that Google `functionCall` parts — which `_formatContent` converts
|
|
888
|
+
// into `tool_use` — count as represented and are not appended twice.
|
|
889
|
+
const representedToolIds = new Set(
|
|
890
|
+
formattedBlocks
|
|
891
|
+
.filter(
|
|
892
|
+
(block) =>
|
|
893
|
+
block != null &&
|
|
894
|
+
(block.type === 'tool_use' || block.type === 'server_tool_use')
|
|
820
895
|
)
|
|
896
|
+
.map((block) => (block as { id?: string }).id)
|
|
821
897
|
);
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
898
|
+
// Client tool calls present in `tool_calls` but absent from the
|
|
899
|
+
// formatted content — e.g. a Bedrock extended-thinking turn records the
|
|
900
|
+
// tool only on `tool_calls` and leaves `content` as just the reasoning
|
|
901
|
+
// block. Without materializing them, dropping that reasoning block
|
|
902
|
+
// silently loses the (handoff) tool call instead of forwarding it.
|
|
903
|
+
const unrepresentedToolCalls = toolCalls.filter(
|
|
904
|
+
(toolCall) =>
|
|
905
|
+
!(
|
|
906
|
+
toolCall.id?.startsWith(Constants.ANTHROPIC_SERVER_TOOL_PREFIX) ??
|
|
907
|
+
false
|
|
908
|
+
) && !representedToolIds.has(toolCall.id)
|
|
909
|
+
);
|
|
910
|
+
if (unrepresentedToolCalls.length === 0) {
|
|
911
|
+
return { role, content: formattedContent };
|
|
826
912
|
}
|
|
913
|
+
const existingBlocks = formattedBlocks.filter(
|
|
914
|
+
(block) =>
|
|
915
|
+
!(
|
|
916
|
+
block != null &&
|
|
917
|
+
block.type === 'text' &&
|
|
918
|
+
'text' in block &&
|
|
919
|
+
block.text === ANTHROPIC_EMPTY_TEXT_PLACEHOLDER
|
|
920
|
+
)
|
|
921
|
+
);
|
|
827
922
|
return {
|
|
828
923
|
role,
|
|
829
|
-
content:
|
|
924
|
+
content: [
|
|
925
|
+
...existingBlocks,
|
|
926
|
+
...unrepresentedToolCalls.map(_convertLangChainToolCallToAnthropic),
|
|
927
|
+
],
|
|
830
928
|
};
|
|
831
929
|
}
|
|
832
930
|
} else {
|
|
@@ -855,6 +953,86 @@ export function modelDisallowsAssistantPrefill(model?: string): boolean {
|
|
|
855
953
|
return Number(match[1]) >= 6;
|
|
856
954
|
}
|
|
857
955
|
|
|
956
|
+
function messagesHaveCacheControl(
|
|
957
|
+
messages: AnthropicMessageCreateParams['messages']
|
|
958
|
+
): boolean {
|
|
959
|
+
return messages.some(
|
|
960
|
+
(message) =>
|
|
961
|
+
Array.isArray(message.content) &&
|
|
962
|
+
message.content.some((block) => 'cache_control' in block)
|
|
963
|
+
);
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
/** Anthropic rejects cache_control on these reasoning blocks. */
|
|
967
|
+
const NON_CACHEABLE_PAYLOAD_BLOCK_TYPES = new Set([
|
|
968
|
+
'thinking',
|
|
969
|
+
'redacted_thinking',
|
|
970
|
+
]);
|
|
971
|
+
|
|
972
|
+
/**
|
|
973
|
+
* Place one ephemeral `cache_control` on the last cacheable block of the final
|
|
974
|
+
* message of an already-converted Anthropic payload. Used to re-anchor the tail
|
|
975
|
+
* breakpoint after a trailing assistant prefill is stripped. Operates on the
|
|
976
|
+
* post-conversion payload, where blocks the converter drops (foreign reasoning,
|
|
977
|
+
* input_json_delta) are already gone — only native thinking blocks must be
|
|
978
|
+
* skipped. Returns a new array only when it actually places a marker.
|
|
979
|
+
*/
|
|
980
|
+
function reanchorTailCacheControl(
|
|
981
|
+
messages: AnthropicMessageCreateParams['messages']
|
|
982
|
+
): AnthropicMessageCreateParams['messages'] {
|
|
983
|
+
if (messages.length === 0) {
|
|
984
|
+
return messages;
|
|
985
|
+
}
|
|
986
|
+
const lastIndex = messages.length - 1;
|
|
987
|
+
const tail = messages[lastIndex];
|
|
988
|
+
const content = tail.content;
|
|
989
|
+
|
|
990
|
+
if (typeof content === 'string') {
|
|
991
|
+
if (content.trim() === '') {
|
|
992
|
+
return messages;
|
|
993
|
+
}
|
|
994
|
+
const next = [...messages];
|
|
995
|
+
next[lastIndex] = {
|
|
996
|
+
...tail,
|
|
997
|
+
content: [
|
|
998
|
+
{ type: 'text', text: content, cache_control: { type: 'ephemeral' } },
|
|
999
|
+
],
|
|
1000
|
+
} as (typeof messages)[number];
|
|
1001
|
+
return next;
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
if (!Array.isArray(content)) {
|
|
1005
|
+
return messages;
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
let anchor = -1;
|
|
1009
|
+
for (let i = 0; i < content.length; i++) {
|
|
1010
|
+
const type = (content[i] as { type?: string }).type;
|
|
1011
|
+
if (type == null || NON_CACHEABLE_PAYLOAD_BLOCK_TYPES.has(type)) {
|
|
1012
|
+
continue;
|
|
1013
|
+
}
|
|
1014
|
+
if (
|
|
1015
|
+
type === 'text' &&
|
|
1016
|
+
((content[i] as { text?: string }).text ?? '').trim() === ''
|
|
1017
|
+
) {
|
|
1018
|
+
continue;
|
|
1019
|
+
}
|
|
1020
|
+
anchor = i;
|
|
1021
|
+
}
|
|
1022
|
+
if (anchor < 0) {
|
|
1023
|
+
return messages;
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
const next = [...messages];
|
|
1027
|
+
next[lastIndex] = {
|
|
1028
|
+
...tail,
|
|
1029
|
+
content: content.map((block, i) =>
|
|
1030
|
+
i === anchor ? { ...block, cache_control: { type: 'ephemeral' } } : block
|
|
1031
|
+
),
|
|
1032
|
+
} as (typeof messages)[number];
|
|
1033
|
+
return next;
|
|
1034
|
+
}
|
|
1035
|
+
|
|
858
1036
|
export function stripUnsupportedAssistantPrefill<
|
|
859
1037
|
T extends Pick<AnthropicMessageCreateParams, 'messages'> & { model?: string },
|
|
860
1038
|
>(request: T): T {
|
|
@@ -878,9 +1056,21 @@ export function stripUnsupportedAssistantPrefill<
|
|
|
878
1056
|
nextMessages.pop();
|
|
879
1057
|
}
|
|
880
1058
|
|
|
1059
|
+
/**
|
|
1060
|
+
* If a single tail prompt-cache breakpoint rode the stripped assistant
|
|
1061
|
+
* prefill, the survivors may now carry no `cache_control` at all, dropping
|
|
1062
|
+
* message caching for this request. Re-anchor the breakpoint on the new tail
|
|
1063
|
+
* (only when one was actually lost, so caching-off requests stay untouched).
|
|
1064
|
+
*/
|
|
1065
|
+
const reanchored =
|
|
1066
|
+
messagesHaveCacheControl(messages) &&
|
|
1067
|
+
!messagesHaveCacheControl(nextMessages)
|
|
1068
|
+
? reanchorTailCacheControl(nextMessages)
|
|
1069
|
+
: nextMessages;
|
|
1070
|
+
|
|
881
1071
|
return {
|
|
882
1072
|
...request,
|
|
883
|
-
messages:
|
|
1073
|
+
messages: reanchored,
|
|
884
1074
|
};
|
|
885
1075
|
}
|
|
886
1076
|
|