@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.
- package/dist/cjs/agents/AgentContext.cjs +102 -35
- package/dist/cjs/agents/AgentContext.cjs.map +1 -1
- package/dist/cjs/graphs/Graph.cjs +13 -0
- package/dist/cjs/graphs/Graph.cjs.map +1 -1
- package/dist/cjs/llm/openai/index.cjs +50 -13
- package/dist/cjs/llm/openai/index.cjs.map +1 -1
- package/dist/cjs/llm/openrouter/index.cjs +17 -7
- package/dist/cjs/llm/openrouter/index.cjs.map +1 -1
- package/dist/cjs/llm/openrouter/toolCache.cjs +55 -0
- package/dist/cjs/llm/openrouter/toolCache.cjs.map +1 -0
- package/dist/cjs/llm/vertexai/index.cjs +15 -15
- package/dist/cjs/llm/vertexai/index.cjs.map +1 -1
- package/dist/cjs/tools/ToolNode.cjs +70 -12
- package/dist/cjs/tools/ToolNode.cjs.map +1 -1
- package/dist/esm/agents/AgentContext.mjs +101 -34
- package/dist/esm/agents/AgentContext.mjs.map +1 -1
- package/dist/esm/graphs/Graph.mjs +13 -0
- package/dist/esm/graphs/Graph.mjs.map +1 -1
- package/dist/esm/llm/openai/index.mjs +50 -14
- package/dist/esm/llm/openai/index.mjs.map +1 -1
- package/dist/esm/llm/openrouter/index.mjs +17 -7
- package/dist/esm/llm/openrouter/index.mjs.map +1 -1
- package/dist/esm/llm/openrouter/toolCache.mjs +53 -0
- package/dist/esm/llm/openrouter/toolCache.mjs.map +1 -0
- package/dist/esm/llm/vertexai/index.mjs +15 -16
- package/dist/esm/llm/vertexai/index.mjs.map +1 -1
- package/dist/esm/tools/ToolNode.mjs +70 -12
- package/dist/esm/tools/ToolNode.mjs.map +1 -1
- package/dist/types/agents/AgentContext.d.ts +6 -1
- package/dist/types/llm/openrouter/index.d.ts +1 -0
- package/dist/types/llm/openrouter/toolCache.d.ts +2 -0
- package/dist/types/llm/vertexai/index.d.ts +18 -1
- package/dist/types/tools/ToolNode.d.ts +5 -0
- package/dist/types/types/run.d.ts +2 -0
- package/package.json +2 -1
- package/src/agents/AgentContext.ts +146 -38
- package/src/agents/__tests__/AgentContext.test.ts +198 -0
- package/src/graphs/Graph.ts +24 -0
- package/src/llm/custom-chat-models.smoke.test.ts +76 -0
- package/src/llm/openai/deepseek.test.ts +14 -1
- package/src/llm/openai/index.ts +38 -12
- package/src/llm/openrouter/index.ts +22 -7
- package/src/llm/openrouter/reasoning.test.ts +33 -0
- package/src/llm/openrouter/toolCache.test.ts +83 -0
- package/src/llm/openrouter/toolCache.ts +89 -0
- package/src/llm/vertexai/fixThoughtSignatures.test.ts +154 -0
- package/src/llm/vertexai/index.ts +16 -22
- package/src/messages/cache.test.ts +127 -0
- package/src/scripts/openrouter_prompt_cache_live.ts +310 -0
- package/src/specs/agent-handoffs.live.test.ts +140 -0
- package/src/specs/agent-handoffs.test.ts +266 -2
- package/src/specs/openrouter.simple.test.ts +15 -8
- package/src/tools/ToolNode.ts +92 -13
- 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:
|
|
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,
|
package/src/llm/openai/index.ts
CHANGED
|
@@ -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
|
|
162
|
-
|
|
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
|
-
|
|
690
|
-
|
|
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
|
-
...(
|
|
694
|
-
audio:
|
|
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
|
-
...(
|
|
697
|
-
|
|
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
|
-
...(
|
|
851
|
-
audio:
|
|
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
|
-
...(
|
|
854
|
-
|
|
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
|
-
} =
|
|
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:
|
|
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 (
|
|
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
|
+
});
|