@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.
Files changed (98) hide show
  1. package/dist/cjs/agents/AgentContext.cjs +75 -2
  2. package/dist/cjs/agents/AgentContext.cjs.map +1 -1
  3. package/dist/cjs/agents/projection.cjs +25 -0
  4. package/dist/cjs/agents/projection.cjs.map +1 -0
  5. package/dist/cjs/graphs/Graph.cjs +10 -26
  6. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  7. package/dist/cjs/langfuse.cjs +16 -5
  8. package/dist/cjs/langfuse.cjs.map +1 -1
  9. package/dist/cjs/langfuseToolOutputTracing.cjs +7 -0
  10. package/dist/cjs/langfuseToolOutputTracing.cjs.map +1 -1
  11. package/dist/cjs/llm/anthropic/utils/message_inputs.cjs +118 -7
  12. package/dist/cjs/llm/anthropic/utils/message_inputs.cjs.map +1 -1
  13. package/dist/cjs/llm/bedrock/utils/message_inputs.cjs +44 -4
  14. package/dist/cjs/llm/bedrock/utils/message_inputs.cjs.map +1 -1
  15. package/dist/cjs/main.cjs +7 -0
  16. package/dist/cjs/messages/budget.cjs +23 -0
  17. package/dist/cjs/messages/budget.cjs.map +1 -0
  18. package/dist/cjs/messages/cache.cjs +184 -0
  19. package/dist/cjs/messages/cache.cjs.map +1 -1
  20. package/dist/cjs/messages/index.cjs +1 -0
  21. package/dist/cjs/summarization/node.cjs +1 -1
  22. package/dist/cjs/summarization/node.cjs.map +1 -1
  23. package/dist/cjs/tools/search/format.cjs +91 -2
  24. package/dist/cjs/tools/search/format.cjs.map +1 -1
  25. package/dist/cjs/tools/search/tool.cjs +4 -3
  26. package/dist/cjs/tools/search/tool.cjs.map +1 -1
  27. package/dist/cjs/tools/toolOutputReferences.cjs +28 -14
  28. package/dist/cjs/tools/toolOutputReferences.cjs.map +1 -1
  29. package/dist/esm/agents/AgentContext.mjs +76 -3
  30. package/dist/esm/agents/AgentContext.mjs.map +1 -1
  31. package/dist/esm/agents/projection.mjs +25 -0
  32. package/dist/esm/agents/projection.mjs.map +1 -0
  33. package/dist/esm/graphs/Graph.mjs +9 -25
  34. package/dist/esm/graphs/Graph.mjs.map +1 -1
  35. package/dist/esm/langfuse.mjs +16 -5
  36. package/dist/esm/langfuse.mjs.map +1 -1
  37. package/dist/esm/langfuseToolOutputTracing.mjs +7 -0
  38. package/dist/esm/langfuseToolOutputTracing.mjs.map +1 -1
  39. package/dist/esm/llm/anthropic/utils/message_inputs.mjs +118 -7
  40. package/dist/esm/llm/anthropic/utils/message_inputs.mjs.map +1 -1
  41. package/dist/esm/llm/bedrock/utils/message_inputs.mjs +44 -4
  42. package/dist/esm/llm/bedrock/utils/message_inputs.mjs.map +1 -1
  43. package/dist/esm/main.mjs +4 -2
  44. package/dist/esm/messages/budget.mjs +23 -0
  45. package/dist/esm/messages/budget.mjs.map +1 -0
  46. package/dist/esm/messages/cache.mjs +182 -1
  47. package/dist/esm/messages/cache.mjs.map +1 -1
  48. package/dist/esm/messages/index.mjs +1 -0
  49. package/dist/esm/summarization/node.mjs +2 -2
  50. package/dist/esm/summarization/node.mjs.map +1 -1
  51. package/dist/esm/tools/search/format.mjs +91 -2
  52. package/dist/esm/tools/search/format.mjs.map +1 -1
  53. package/dist/esm/tools/search/tool.mjs +4 -3
  54. package/dist/esm/tools/search/tool.mjs.map +1 -1
  55. package/dist/esm/tools/toolOutputReferences.mjs +28 -14
  56. package/dist/esm/tools/toolOutputReferences.mjs.map +1 -1
  57. package/dist/types/agents/AgentContext.d.ts +30 -1
  58. package/dist/types/agents/projection.d.ts +26 -0
  59. package/dist/types/index.d.ts +1 -0
  60. package/dist/types/messages/budget.d.ts +11 -0
  61. package/dist/types/messages/cache.d.ts +47 -0
  62. package/dist/types/messages/index.d.ts +1 -0
  63. package/dist/types/tools/search/format.d.ts +4 -1
  64. package/dist/types/tools/search/types.d.ts +7 -0
  65. package/dist/types/types/graph.d.ts +2 -0
  66. package/package.json +2 -1
  67. package/src/agents/AgentContext.ts +105 -4
  68. package/src/agents/__tests__/AgentContext.test.ts +232 -9
  69. package/src/agents/__tests__/projection.test.ts +73 -0
  70. package/src/agents/projection.ts +46 -0
  71. package/src/graphs/Graph.ts +66 -65
  72. package/src/index.ts +3 -0
  73. package/src/langfuse.ts +38 -4
  74. package/src/langfuseToolOutputTracing.ts +18 -0
  75. package/src/llm/anthropic/utils/cross-provider-reasoning.test.ts +317 -0
  76. package/src/llm/anthropic/utils/message_inputs.ts +209 -19
  77. package/src/llm/anthropic/utils/stripPrefillCache.test.ts +111 -0
  78. package/src/llm/bedrock/utils/cross-provider-reasoning.test.ts +131 -0
  79. package/src/llm/bedrock/utils/message_inputs.test.ts +129 -0
  80. package/src/llm/bedrock/utils/message_inputs.ts +81 -4
  81. package/src/llm/bedrock/utils/toolResultCachePoint.test.ts +103 -0
  82. package/src/messages/budget.ts +32 -0
  83. package/src/messages/cache.tail.test.ts +340 -0
  84. package/src/messages/cache.ts +267 -1
  85. package/src/messages/index.ts +1 -0
  86. package/src/messages/tailCacheConversion.test.ts +161 -0
  87. package/src/scripts/bench-prompt-cache.ts +479 -0
  88. package/src/specs/langfuse-config.test.ts +69 -2
  89. package/src/specs/langfuse-metadata.test.ts +44 -0
  90. package/src/specs/langfuse-tool-output-tracing.test.ts +6 -0
  91. package/src/summarization/node.ts +2 -2
  92. package/src/tools/__tests__/annotateMessagesForLLM.test.ts +50 -0
  93. package/src/tools/search/format.test.ts +242 -0
  94. package/src/tools/search/format.ts +122 -5
  95. package/src/tools/search/tool.ts +5 -1
  96. package/src/tools/search/types.ts +7 -0
  97. package/src/tools/toolOutputReferences.ts +34 -20
  98. 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
- ...(toolMessageContent != null
192
- ? { content: _formatContent(message) }
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((toolCall) => toolCall.id === contentPartCopy.id)
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((acc, part) => acc + (part as Record<string, unknown>).input, '');
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 { content } = message;
812
- const hasMismatchedToolCalls = !toolCalls.every(
813
- (toolCall) =>
814
- !!content.find(
815
- (contentPart) =>
816
- (contentPart.type === 'tool_use' ||
817
- contentPart.type === 'input_json_delta' ||
818
- contentPart.type === 'server_tool_use') &&
819
- contentPart.id === toolCall.id
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
- if (hasMismatchedToolCalls) {
823
- console.warn(
824
- 'The "tool_calls" field on a message is only respected if content is a string.'
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: _formatContent(message),
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: nextMessages,
1073
+ messages: reanchored,
884
1074
  };
885
1075
  }
886
1076