@librechat/agents 3.1.80 → 3.1.82

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 (54) hide show
  1. package/dist/cjs/agents/AgentContext.cjs +102 -35
  2. package/dist/cjs/agents/AgentContext.cjs.map +1 -1
  3. package/dist/cjs/graphs/Graph.cjs +13 -0
  4. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  5. package/dist/cjs/llm/openai/index.cjs +50 -13
  6. package/dist/cjs/llm/openai/index.cjs.map +1 -1
  7. package/dist/cjs/llm/openrouter/index.cjs +17 -7
  8. package/dist/cjs/llm/openrouter/index.cjs.map +1 -1
  9. package/dist/cjs/llm/openrouter/toolCache.cjs +55 -0
  10. package/dist/cjs/llm/openrouter/toolCache.cjs.map +1 -0
  11. package/dist/cjs/llm/vertexai/index.cjs +15 -15
  12. package/dist/cjs/llm/vertexai/index.cjs.map +1 -1
  13. package/dist/cjs/tools/ToolNode.cjs +70 -12
  14. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  15. package/dist/esm/agents/AgentContext.mjs +101 -34
  16. package/dist/esm/agents/AgentContext.mjs.map +1 -1
  17. package/dist/esm/graphs/Graph.mjs +13 -0
  18. package/dist/esm/graphs/Graph.mjs.map +1 -1
  19. package/dist/esm/llm/openai/index.mjs +50 -14
  20. package/dist/esm/llm/openai/index.mjs.map +1 -1
  21. package/dist/esm/llm/openrouter/index.mjs +17 -7
  22. package/dist/esm/llm/openrouter/index.mjs.map +1 -1
  23. package/dist/esm/llm/openrouter/toolCache.mjs +53 -0
  24. package/dist/esm/llm/openrouter/toolCache.mjs.map +1 -0
  25. package/dist/esm/llm/vertexai/index.mjs +15 -16
  26. package/dist/esm/llm/vertexai/index.mjs.map +1 -1
  27. package/dist/esm/tools/ToolNode.mjs +70 -12
  28. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  29. package/dist/types/agents/AgentContext.d.ts +6 -1
  30. package/dist/types/llm/openrouter/index.d.ts +1 -0
  31. package/dist/types/llm/openrouter/toolCache.d.ts +2 -0
  32. package/dist/types/llm/vertexai/index.d.ts +18 -1
  33. package/dist/types/tools/ToolNode.d.ts +5 -0
  34. package/dist/types/types/run.d.ts +2 -0
  35. package/package.json +2 -1
  36. package/src/agents/AgentContext.ts +146 -38
  37. package/src/agents/__tests__/AgentContext.test.ts +198 -0
  38. package/src/graphs/Graph.ts +24 -0
  39. package/src/llm/custom-chat-models.smoke.test.ts +76 -0
  40. package/src/llm/openai/deepseek.test.ts +14 -1
  41. package/src/llm/openai/index.ts +38 -12
  42. package/src/llm/openrouter/index.ts +22 -7
  43. package/src/llm/openrouter/reasoning.test.ts +33 -0
  44. package/src/llm/openrouter/toolCache.test.ts +83 -0
  45. package/src/llm/openrouter/toolCache.ts +89 -0
  46. package/src/llm/vertexai/fixThoughtSignatures.test.ts +154 -0
  47. package/src/llm/vertexai/index.ts +16 -22
  48. package/src/messages/cache.test.ts +127 -0
  49. package/src/scripts/openrouter_prompt_cache_live.ts +310 -0
  50. package/src/specs/agent-handoffs.live.test.ts +140 -0
  51. package/src/specs/agent-handoffs.test.ts +266 -2
  52. package/src/specs/openrouter.simple.test.ts +15 -8
  53. package/src/tools/ToolNode.ts +92 -13
  54. package/src/types/run.ts +2 -0
@@ -120,6 +120,17 @@ type OpenRouterReasoningStreamChoice = Omit<
120
120
  > & {
121
121
  delta: OpenRouterReasoningStreamDelta;
122
122
  };
123
+ type PromptTokensDetailsWithCacheWrite = NonNullable<
124
+ OpenAIClient.Completions.CompletionUsage['prompt_tokens_details']
125
+ > & {
126
+ cache_write_tokens?: number;
127
+ };
128
+ type CompletionUsageWithCacheWrite = Omit<
129
+ OpenAIClient.Completions.CompletionUsage,
130
+ 'prompt_tokens_details'
131
+ > & {
132
+ prompt_tokens_details?: PromptTokensDetailsWithCacheWrite;
133
+ };
123
134
  type OpenAIStreamModel = ChatOpenAI | AzureChatOpenAI;
124
135
 
125
136
  const baseAzureFields = {
@@ -654,6 +665,71 @@ describe('custom chat model class smoke tests', () => {
654
665
  ]);
655
666
  });
656
667
 
668
+ it('maps OpenRouter cache write usage to cache_creation in streaming responses', async () => {
669
+ const model = new ChatOpenRouter({
670
+ model: 'anthropic/claude-sonnet-test',
671
+ apiKey: 'test-key',
672
+ streamUsage: true,
673
+ });
674
+ const completions = (model as unknown as StreamingCompletionBackedModel)
675
+ .completions;
676
+ const usage: CompletionUsageWithCacheWrite = {
677
+ prompt_tokens: 11,
678
+ completion_tokens: 7,
679
+ total_tokens: 18,
680
+ prompt_tokens_details: {
681
+ audio_tokens: 2,
682
+ cached_tokens: 3,
683
+ cache_write_tokens: 5,
684
+ },
685
+ completion_tokens_details: {
686
+ audio_tokens: 4,
687
+ reasoning_tokens: 6,
688
+ },
689
+ };
690
+
691
+ async function* streamChunks(): AsyncGenerator<OpenAIClient.Chat.Completions.ChatCompletionChunk> {
692
+ yield createOpenAIStreamChunk('answer', 'stop');
693
+ yield {
694
+ id: 'chatcmpl-openrouter-usage',
695
+ object: 'chat.completion.chunk',
696
+ created: 0,
697
+ model: 'anthropic/claude-sonnet-test',
698
+ choices: [],
699
+ usage,
700
+ } as OpenAIClient.Chat.Completions.ChatCompletionChunk;
701
+ }
702
+
703
+ completions.completionWithRetry = async (): Promise<
704
+ AsyncIterable<OpenAIClient.Chat.Completions.ChatCompletionChunk>
705
+ > => streamChunks();
706
+
707
+ const chunks: AIMessageChunk[] = [];
708
+ const stream = await model.stream([new HumanMessage('hi')]);
709
+ for await (const chunk of stream) {
710
+ chunks.push(chunk);
711
+ }
712
+
713
+ const usageChunk = chunks.find(
714
+ (chunk) =>
715
+ chunk.usage_metadata?.input_token_details?.cache_creation === 5
716
+ );
717
+ expect(usageChunk?.usage_metadata).toEqual({
718
+ input_tokens: 11,
719
+ output_tokens: 7,
720
+ total_tokens: 18,
721
+ input_token_details: {
722
+ audio: 2,
723
+ cache_read: 3,
724
+ cache_creation: 5,
725
+ },
726
+ output_token_details: {
727
+ audio: 4,
728
+ reasoning: 6,
729
+ },
730
+ });
731
+ });
732
+
657
733
  it('keeps Anthropic output, residency, compaction, and stream-delay options', () => {
658
734
  const contextManagement = {
659
735
  edits: [
@@ -11,6 +11,17 @@ type DeepSeekRequest =
11
11
  type OpenAIChatCompletion = OpenAIClient.Chat.Completions.ChatCompletion;
12
12
  type OpenAIChatCompletionChunk =
13
13
  OpenAIClient.Chat.Completions.ChatCompletionChunk;
14
+ type PromptTokensDetailsWithCacheWrite = NonNullable<
15
+ OpenAIClient.Completions.CompletionUsage['prompt_tokens_details']
16
+ > & {
17
+ cache_write_tokens?: number;
18
+ };
19
+ type CompletionUsageWithCacheWrite = Omit<
20
+ OpenAIClient.Completions.CompletionUsage,
21
+ 'prompt_tokens_details'
22
+ > & {
23
+ prompt_tokens_details?: PromptTokensDetailsWithCacheWrite;
24
+ };
14
25
  type ReasoningAssistantMessageParam =
15
26
  OpenAIClient.Chat.Completions.ChatCompletionAssistantMessageParam & {
16
27
  reasoning_content?: string;
@@ -129,7 +140,7 @@ async function* createCompletionStream(
129
140
  }
130
141
 
131
142
  function createCompletion(
132
- usage: OpenAIClient.Completions.CompletionUsage = {
143
+ usage: CompletionUsageWithCacheWrite = {
133
144
  prompt_tokens: 1,
134
145
  completion_tokens: 1,
135
146
  total_tokens: 2,
@@ -392,6 +403,7 @@ describe('ChatDeepSeek', () => {
392
403
  prompt_tokens_details: {
393
404
  audio_tokens: 2,
394
405
  cached_tokens: 3,
406
+ cache_write_tokens: 6,
395
407
  },
396
408
  completion_tokens_details: {
397
409
  audio_tokens: 4,
@@ -409,6 +421,7 @@ describe('ChatDeepSeek', () => {
409
421
  input_token_details: {
410
422
  audio: 2,
411
423
  cache_read: 3,
424
+ cache_creation: 6,
412
425
  },
413
426
  output_token_details: {
414
427
  audio: 4,
@@ -134,6 +134,11 @@ type OpenAIChatCompletionRequest =
134
134
  type OpenAIChatCompletionResult =
135
135
  | AsyncIterable<OpenAIChatCompletionChunk>
136
136
  | OpenAIChatCompletion;
137
+ type PromptTokensDetailsWithCacheWrite = NonNullable<
138
+ OpenAIClient.Completions.CompletionUsage['prompt_tokens_details']
139
+ > & {
140
+ cache_write_tokens?: number;
141
+ };
137
142
  type OpenAIChatCompletionRetry = (
138
143
  request: OpenAIChatCompletionRequest,
139
144
  requestOptions?: OpenAICoreRequestOptions
@@ -158,8 +163,12 @@ function createUsageMetadata(
158
163
  const outputTokenDetails: UsageMetadata['output_token_details'] = {};
159
164
  let hasInputTokenDetails = false;
160
165
  let hasOutputTokenDetails = false;
161
- const audioInputTokens = usage.prompt_tokens_details?.audio_tokens;
162
- const cachedInputTokens = usage.prompt_tokens_details?.cached_tokens;
166
+ const promptTokenDetails = usage.prompt_tokens_details as
167
+ | PromptTokensDetailsWithCacheWrite
168
+ | undefined;
169
+ const audioInputTokens = promptTokenDetails?.audio_tokens;
170
+ const cachedInputTokens = promptTokenDetails?.cached_tokens;
171
+ const cacheWriteInputTokens = promptTokenDetails?.cache_write_tokens;
163
172
  const audioOutputTokens = usage.completion_tokens_details?.audio_tokens;
164
173
  const reasoningOutputTokens =
165
174
  usage.completion_tokens_details?.reasoning_tokens;
@@ -172,6 +181,10 @@ function createUsageMetadata(
172
181
  inputTokenDetails.cache_read = cachedInputTokens;
173
182
  hasInputTokenDetails = true;
174
183
  }
184
+ if (cacheWriteInputTokens != null) {
185
+ inputTokenDetails.cache_creation = cacheWriteInputTokens;
186
+ hasInputTokenDetails = true;
187
+ }
175
188
  if (audioOutputTokens != null) {
176
189
  outputTokenDetails.audio = audioOutputTokens;
177
190
  hasOutputTokenDetails = true;
@@ -685,16 +698,23 @@ class LibreChatOpenAICompletions extends OriginalChatOpenAICompletions {
685
698
  usageMetadata.total_tokens =
686
699
  (usageMetadata.total_tokens ?? 0) + totalTokens;
687
700
  }
701
+ const promptTokensDetailsWithCacheWrite = promptTokensDetails as
702
+ | PromptTokensDetailsWithCacheWrite
703
+ | undefined;
688
704
  if (
689
- promptTokensDetails?.audio_tokens != null ||
690
- promptTokensDetails?.cached_tokens != null
705
+ promptTokensDetailsWithCacheWrite?.audio_tokens != null ||
706
+ promptTokensDetailsWithCacheWrite?.cached_tokens != null ||
707
+ promptTokensDetailsWithCacheWrite?.cache_write_tokens != null
691
708
  ) {
692
709
  usageMetadata.input_token_details = {
693
- ...(promptTokensDetails.audio_tokens != null && {
694
- audio: promptTokensDetails.audio_tokens,
710
+ ...(promptTokensDetailsWithCacheWrite.audio_tokens != null && {
711
+ audio: promptTokensDetailsWithCacheWrite.audio_tokens,
712
+ }),
713
+ ...(promptTokensDetailsWithCacheWrite.cached_tokens != null && {
714
+ cache_read: promptTokensDetailsWithCacheWrite.cached_tokens,
695
715
  }),
696
- ...(promptTokensDetails.cached_tokens != null && {
697
- cache_read: promptTokensDetails.cached_tokens,
716
+ ...(promptTokensDetailsWithCacheWrite.cache_write_tokens != null && {
717
+ cache_creation: promptTokensDetailsWithCacheWrite.cache_write_tokens,
698
718
  }),
699
719
  };
700
720
  }
@@ -846,12 +866,18 @@ class LibreChatOpenAICompletions extends OriginalChatOpenAICompletions {
846
866
  );
847
867
  }
848
868
  if (usage) {
869
+ const promptTokenDetails = usage.prompt_tokens_details as
870
+ | PromptTokensDetailsWithCacheWrite
871
+ | undefined;
849
872
  const inputTokenDetails = {
850
- ...(usage.prompt_tokens_details?.audio_tokens != null && {
851
- audio: usage.prompt_tokens_details.audio_tokens,
873
+ ...(promptTokenDetails?.audio_tokens != null && {
874
+ audio: promptTokenDetails.audio_tokens,
875
+ }),
876
+ ...(promptTokenDetails?.cached_tokens != null && {
877
+ cache_read: promptTokenDetails.cached_tokens,
852
878
  }),
853
- ...(usage.prompt_tokens_details?.cached_tokens != null && {
854
- cache_read: usage.prompt_tokens_details.cached_tokens,
879
+ ...(promptTokenDetails?.cache_write_tokens != null && {
880
+ cache_creation: promptTokenDetails.cache_write_tokens,
855
881
  }),
856
882
  };
857
883
  const outputTokenDetails = {
@@ -29,6 +29,7 @@ export interface ChatOpenRouterCallOptions
29
29
  include_reasoning?: boolean;
30
30
  reasoning?: OpenRouterReasoning;
31
31
  modelKwargs?: OpenAIChatInput['modelKwargs'];
32
+ promptCache?: boolean;
32
33
  }
33
34
 
34
35
  export type ChatOpenRouterInput = Partial<
@@ -104,31 +105,45 @@ export class ChatOpenRouter extends ChatOpenAI {
104
105
  private includeReasoning?: boolean;
105
106
 
106
107
  constructor(_fields: ChatOpenRouterInput) {
108
+ const fieldsWithoutPromptCache: ChatOpenRouterInput = { ..._fields };
109
+ delete fieldsWithoutPromptCache.promptCache;
110
+
107
111
  const {
108
112
  include_reasoning,
109
113
  reasoning: openRouterReasoning,
110
114
  modelKwargs = {},
111
115
  ...fields
112
- } = _fields;
116
+ } = fieldsWithoutPromptCache;
113
117
 
114
118
  // Extract reasoning from modelKwargs if provided there (e.g., from LLMConfig)
115
119
  const { reasoning: mkReasoning, ...restModelKwargs } = modelKwargs as {
116
120
  reasoning?: OpenRouterReasoning;
117
121
  } & Record<string, unknown>;
122
+ const mergedReasoning =
123
+ mkReasoning != null || openRouterReasoning != null
124
+ ? {
125
+ ...mkReasoning,
126
+ ...openRouterReasoning,
127
+ }
128
+ : undefined;
129
+ const runtimeReasoning =
130
+ mergedReasoning ??
131
+ (include_reasoning === true ? { enabled: true } : undefined);
132
+ const parentModelKwargs =
133
+ runtimeReasoning == null
134
+ ? restModelKwargs
135
+ : { ...restModelKwargs, reasoning: runtimeReasoning };
118
136
 
119
137
  super({
120
138
  ...fields,
121
- modelKwargs: restModelKwargs,
139
+ modelKwargs: parentModelKwargs,
122
140
  includeReasoningDetails: true,
123
141
  convertReasoningDetailsToContent: true,
124
142
  });
125
143
 
126
144
  // Merge reasoning config: modelKwargs.reasoning < constructor reasoning
127
- if (mkReasoning != null || openRouterReasoning != null) {
128
- this.openRouterReasoning = {
129
- ...mkReasoning,
130
- ...openRouterReasoning,
131
- };
145
+ if (mergedReasoning != null) {
146
+ this.openRouterReasoning = mergedReasoning;
132
147
  }
133
148
 
134
149
  this.includeReasoning = include_reasoning;
@@ -7,6 +7,17 @@ type CreateRouterOptions = Partial<
7
7
  Pick<OpenAIChatInput, 'model' | 'apiKey' | 'streamUsage'>
8
8
  >;
9
9
 
10
+ type RuntimeInvocationParams = {
11
+ reasoning?: OpenRouterReasoning;
12
+ reasoning_effort?: string;
13
+ };
14
+
15
+ class RuntimeInspectableChatOpenRouter extends ChatOpenRouter {
16
+ getRuntimeInvocationParams(): RuntimeInvocationParams {
17
+ return this.completions.invocationParams() as RuntimeInvocationParams;
18
+ }
19
+ }
20
+
10
21
  function createRouter(overrides: CreateRouterOptions = {}): ChatOpenRouter {
11
22
  return new ChatOpenRouter({
12
23
  model: 'openrouter/test-model',
@@ -91,6 +102,28 @@ describe('ChatOpenRouter reasoning handling', () => {
91
102
  expect(params.reasoning_effort).toBeUndefined();
92
103
  });
93
104
 
105
+ it('passes reasoning to the runtime completions delegate', () => {
106
+ const router = new RuntimeInspectableChatOpenRouter({
107
+ model: 'openrouter/test-model',
108
+ apiKey: 'test-key',
109
+ reasoning: { max_tokens: 1024 },
110
+ });
111
+ const params = router.getRuntimeInvocationParams();
112
+ expect(params.reasoning).toEqual({ max_tokens: 1024 });
113
+ expect(params.reasoning_effort).toBeUndefined();
114
+ });
115
+
116
+ it('passes legacy include_reasoning to the runtime completions delegate', () => {
117
+ const router = new RuntimeInspectableChatOpenRouter({
118
+ model: 'openrouter/test-model',
119
+ apiKey: 'test-key',
120
+ include_reasoning: true,
121
+ });
122
+ const params = router.getRuntimeInvocationParams();
123
+ expect(params.reasoning).toEqual({ enabled: true });
124
+ expect(params.reasoning_effort).toBeUndefined();
125
+ });
126
+
94
127
  it('does not include reasoning when none is configured', () => {
95
128
  const router = createRouter();
96
129
  const params = router.invocationParams();
@@ -0,0 +1,83 @@
1
+ import { tool } from '@langchain/core/tools';
2
+ import type { GraphTools } from '@/types';
3
+ import { partitionAndMarkOpenRouterToolCache } from './toolCache';
4
+
5
+ type OpenRouterTool = {
6
+ type: 'function';
7
+ function: {
8
+ name: string;
9
+ description?: string;
10
+ parameters?: object;
11
+ };
12
+ cache_control?: { type: 'ephemeral' };
13
+ defer_loading?: boolean;
14
+ };
15
+
16
+ function createOpenAITool(name: string): OpenRouterTool {
17
+ return {
18
+ type: 'function',
19
+ function: {
20
+ name,
21
+ description: `${name} description`,
22
+ parameters: {
23
+ type: 'object',
24
+ properties: {},
25
+ },
26
+ },
27
+ };
28
+ }
29
+
30
+ describe('partitionAndMarkOpenRouterToolCache', () => {
31
+ it('marks the last static OpenRouter tool before deferred tools', () => {
32
+ const tools = [
33
+ createOpenAITool('static_one'),
34
+ createOpenAITool('static_two'),
35
+ createOpenAITool('dynamic_one'),
36
+ ] as GraphTools;
37
+
38
+ const result = partitionAndMarkOpenRouterToolCache(
39
+ tools,
40
+ (name) => name === 'dynamic_one'
41
+ ) as OpenRouterTool[];
42
+
43
+ expect(result.map((entry) => entry.function.name)).toEqual([
44
+ 'static_one',
45
+ 'static_two',
46
+ 'dynamic_one',
47
+ ]);
48
+ expect(result[0]).not.toHaveProperty('cache_control');
49
+ expect(result[1].cache_control).toEqual({ type: 'ephemeral' });
50
+ expect(result[2]).not.toHaveProperty('cache_control');
51
+ });
52
+
53
+ it('converts LangChain tools to OpenAI tools before adding cache control', () => {
54
+ const staticTool = tool(async () => 'static', {
55
+ name: 'static_tool',
56
+ description: 'Static tool',
57
+ schema: {
58
+ type: 'object',
59
+ properties: {},
60
+ },
61
+ });
62
+ const dynamicTool = tool(async () => 'dynamic', {
63
+ name: 'dynamic_tool',
64
+ description: 'Dynamic tool',
65
+ schema: {
66
+ type: 'object',
67
+ properties: {},
68
+ },
69
+ });
70
+
71
+ const result = partitionAndMarkOpenRouterToolCache(
72
+ [dynamicTool, staticTool] as GraphTools,
73
+ (name) => name === 'dynamic_tool'
74
+ ) as OpenRouterTool[];
75
+
76
+ expect(result.map((entry) => entry.function.name)).toEqual([
77
+ 'static_tool',
78
+ 'dynamic_tool',
79
+ ]);
80
+ expect(result[0].cache_control).toEqual({ type: 'ephemeral' });
81
+ expect(result[1]).not.toHaveProperty('cache_control');
82
+ });
83
+ });
@@ -0,0 +1,89 @@
1
+ import type { BindToolsInput } from '@langchain/core/language_models/chat_models';
2
+ import type { OpenAIClient } from '@langchain/openai';
3
+ import type { GraphTools } from '@/types';
4
+ import { _convertToOpenAITool } from '@/llm/openai';
5
+
6
+ const CACHE_CONTROL = { type: 'ephemeral' as const };
7
+
8
+ type OpenRouterToolWithCacheControl = OpenAIClient.ChatCompletionTool & {
9
+ cache_control?: typeof CACHE_CONTROL;
10
+ defer_loading?: boolean;
11
+ };
12
+
13
+ type ToolNameCandidate = {
14
+ name?: unknown;
15
+ function?: {
16
+ name?: unknown;
17
+ };
18
+ defer_loading?: unknown;
19
+ };
20
+
21
+ function getToolName(tool: unknown): string | undefined {
22
+ const candidate = tool as ToolNameCandidate;
23
+ if (typeof candidate.name === 'string') {
24
+ return candidate.name;
25
+ }
26
+ if (typeof candidate.function?.name === 'string') {
27
+ return candidate.function.name;
28
+ }
29
+ return undefined;
30
+ }
31
+
32
+ function hasDeferredMarker(tool: unknown): boolean {
33
+ return (tool as ToolNameCandidate).defer_loading === true;
34
+ }
35
+
36
+ function toOpenRouterTool(tool: unknown): OpenRouterToolWithCacheControl {
37
+ const converted = _convertToOpenAITool(
38
+ tool as BindToolsInput
39
+ ) as OpenRouterToolWithCacheControl;
40
+
41
+ if (hasDeferredMarker(tool)) {
42
+ return { ...converted, defer_loading: true };
43
+ }
44
+
45
+ return converted;
46
+ }
47
+
48
+ function markCacheControl(
49
+ tool: OpenRouterToolWithCacheControl
50
+ ): OpenRouterToolWithCacheControl {
51
+ return {
52
+ ...tool,
53
+ cache_control: CACHE_CONTROL,
54
+ };
55
+ }
56
+
57
+ export function partitionAndMarkOpenRouterToolCache(
58
+ tools: GraphTools | undefined,
59
+ isDeferred: (toolName: string) => boolean
60
+ ): GraphTools | undefined {
61
+ if (tools == null || tools.length === 0) {
62
+ return tools;
63
+ }
64
+
65
+ const staticTools: OpenRouterToolWithCacheControl[] = [];
66
+ const deferredTools: OpenRouterToolWithCacheControl[] = [];
67
+
68
+ for (const tool of tools as readonly unknown[]) {
69
+ const converted = toOpenRouterTool(tool);
70
+ const name = getToolName(converted) ?? getToolName(tool);
71
+
72
+ if (name != null && isDeferred(name)) {
73
+ deferredTools.push(converted);
74
+ continue;
75
+ }
76
+
77
+ staticTools.push(converted);
78
+ }
79
+
80
+ if (staticTools.length === 0) {
81
+ return [...deferredTools] as GraphTools;
82
+ }
83
+
84
+ staticTools[staticTools.length - 1] = markCacheControl(
85
+ staticTools[staticTools.length - 1]
86
+ );
87
+
88
+ return [...staticTools, ...deferredTools] as GraphTools;
89
+ }
@@ -0,0 +1,154 @@
1
+ import { expect, test, describe } from '@jest/globals';
2
+ import type { GeminiContent } from '@langchain/google-common';
3
+ import { AIMessage, HumanMessage, ToolMessage } from '@langchain/core/messages';
4
+ import { fixThoughtSignatures } from './index';
5
+
6
+ const SIG_A = 'AY89a1/sigA==';
7
+ const SIG_B = 'AY89a1/sigB==';
8
+
9
+ const buildContents = (
10
+ blocks: Array<['user' | 'model' | 'function', GeminiContent['parts']]>
11
+ ): GeminiContent[] =>
12
+ blocks.map(([role, parts]) => ({ role, parts }) as GeminiContent);
13
+
14
+ describe('fixThoughtSignatures', () => {
15
+ test('attaches signature to functionCall part when prior turn is a plain-text AI message (issue LibreChat#13006-followup)', () => {
16
+ // Reproduces the live failure from the issue: a Gemini 3 conversation
17
+ // where turn 1 was plain text ("Hello!") and turn 2 emitted a tool call
18
+ // with a thought signature. The plain-text AI message has no signatures,
19
+ // so the old position-by-filter code matched the toolcall AIMessage with
20
+ // the WRONG model content.
21
+ const helloAi = new AIMessage('Hello! How can I help you today?');
22
+ const toolcallAi = new AIMessage({
23
+ content: '',
24
+ tool_calls: [
25
+ { name: 'bash_tool', args: { command: 'echo hi' }, id: 'tc1' },
26
+ ],
27
+ additional_kwargs: { signatures: [SIG_A, ''] },
28
+ });
29
+ const input = [
30
+ new HumanMessage('hi there'),
31
+ helloAi,
32
+ new HumanMessage('run something'),
33
+ toolcallAi,
34
+ new ToolMessage({ content: 'ok', tool_call_id: 'tc1' }),
35
+ ];
36
+
37
+ const contents = buildContents([
38
+ ['user', [{ text: 'hi there' }]],
39
+ ['model', [{ text: 'Hello! How can I help you today?' }]],
40
+ ['user', [{ text: 'run something' }]],
41
+ [
42
+ 'model',
43
+ [{ functionCall: { name: 'bash_tool', args: { command: 'echo hi' } } }],
44
+ ],
45
+ [
46
+ 'user',
47
+ [
48
+ {
49
+ functionResponse: {
50
+ name: 'bash_tool',
51
+ response: { content: 'ok' },
52
+ },
53
+ },
54
+ ],
55
+ ],
56
+ ]);
57
+
58
+ fixThoughtSignatures(contents, input);
59
+
60
+ expect(contents[1].parts[0].thoughtSignature).toBeUndefined();
61
+ expect(contents[3].parts[0]).toMatchObject({
62
+ functionCall: { name: 'bash_tool' },
63
+ thoughtSignature: SIG_A,
64
+ });
65
+ });
66
+
67
+ test('attaches signatures across multiple tool-call turns by position', () => {
68
+ const turn1 = new AIMessage({
69
+ content: '',
70
+ tool_calls: [{ name: 'a', args: {}, id: 't1' }],
71
+ additional_kwargs: { signatures: [SIG_A, ''] },
72
+ });
73
+ const turn2 = new AIMessage({
74
+ content: '',
75
+ tool_calls: [{ name: 'b', args: {}, id: 't2' }],
76
+ additional_kwargs: { signatures: [SIG_B, ''] },
77
+ });
78
+
79
+ const input = [
80
+ new HumanMessage('q1'),
81
+ turn1,
82
+ new ToolMessage({ content: '1', tool_call_id: 't1' }),
83
+ new HumanMessage('q2'),
84
+ turn2,
85
+ new ToolMessage({ content: '2', tool_call_id: 't2' }),
86
+ ];
87
+ const contents = buildContents([
88
+ ['user', [{ text: 'q1' }]],
89
+ ['model', [{ functionCall: { name: 'a', args: {} } }]],
90
+ ['user', [{ functionResponse: { name: 'a', response: {} } }]],
91
+ ['user', [{ text: 'q2' }]],
92
+ ['model', [{ functionCall: { name: 'b', args: {} } }]],
93
+ ['user', [{ functionResponse: { name: 'b', response: {} } }]],
94
+ ]);
95
+
96
+ fixThoughtSignatures(contents, input);
97
+
98
+ expect(contents[1].parts[0].thoughtSignature).toBe(SIG_A);
99
+ expect(contents[4].parts[0].thoughtSignature).toBe(SIG_B);
100
+ });
101
+
102
+ test('does not overwrite signatures already attached by the library', () => {
103
+ const ai = new AIMessage({
104
+ content: '',
105
+ tool_calls: [{ name: 'a', args: {}, id: 't1' }],
106
+ additional_kwargs: { signatures: [SIG_A] },
107
+ });
108
+ const input = [new HumanMessage('q'), ai];
109
+ const contents = buildContents([
110
+ ['user', [{ text: 'q' }]],
111
+ [
112
+ 'model',
113
+ [{ functionCall: { name: 'a', args: {} }, thoughtSignature: SIG_B }],
114
+ ],
115
+ ]);
116
+
117
+ fixThoughtSignatures(contents, input);
118
+
119
+ expect(contents[1].parts[0].thoughtSignature).toBe(SIG_B);
120
+ });
121
+
122
+ test('no-op when AI message has no signatures', () => {
123
+ const ai = new AIMessage({
124
+ content: '',
125
+ tool_calls: [{ name: 'a', args: {}, id: 't1' }],
126
+ });
127
+ const input = [new HumanMessage('q'), ai];
128
+ const contents = buildContents([
129
+ ['user', [{ text: 'q' }]],
130
+ ['model', [{ functionCall: { name: 'a', args: {} } }]],
131
+ ]);
132
+
133
+ fixThoughtSignatures(contents, input);
134
+
135
+ expect(contents[1].parts[0].thoughtSignature).toBeUndefined();
136
+ });
137
+
138
+ test('skips empty-string signatures', () => {
139
+ const ai = new AIMessage({
140
+ content: '',
141
+ tool_calls: [{ name: 'a', args: {}, id: 't1' }],
142
+ additional_kwargs: { signatures: ['', '', ''] },
143
+ });
144
+ const input = [new HumanMessage('q'), ai];
145
+ const contents = buildContents([
146
+ ['user', [{ text: 'q' }]],
147
+ ['model', [{ functionCall: { name: 'a', args: {} } }]],
148
+ ]);
149
+
150
+ fixThoughtSignatures(contents, input);
151
+
152
+ expect(contents[1].parts[0].thoughtSignature).toBeUndefined();
153
+ });
154
+ });