@librechat/agents 3.1.81 → 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/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/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/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/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
|
+
}
|
|
@@ -14,9 +14,14 @@ import {
|
|
|
14
14
|
addBedrockCacheControl,
|
|
15
15
|
addCacheControl,
|
|
16
16
|
} from './cache';
|
|
17
|
+
import { _convertMessagesToOpenAIParams } from '@/llm/openai/utils';
|
|
17
18
|
import { toLangChainContent } from './langchain';
|
|
18
19
|
import { ContentTypes } from '@/common/enum';
|
|
19
20
|
|
|
21
|
+
type CacheControlBlock = MessageContentComplex & {
|
|
22
|
+
cache_control?: { type: 'ephemeral'; ttl?: '1h' };
|
|
23
|
+
};
|
|
24
|
+
|
|
20
25
|
describe('addCacheControl', () => {
|
|
21
26
|
test('should add cache control to the last two user messages with array content', () => {
|
|
22
27
|
const messages: AnthropicMessages = [
|
|
@@ -1483,3 +1488,125 @@ describe('LangChain message type preservation', () => {
|
|
|
1483
1488
|
expect((result[1] as AIMessage).tool_calls![0].name).toBe('navigate');
|
|
1484
1489
|
});
|
|
1485
1490
|
});
|
|
1491
|
+
|
|
1492
|
+
describe('OpenRouter prompt caching (reuses addCacheControl)', () => {
|
|
1493
|
+
it('adds cache_control to LangChain messages for OpenRouter (same format as Anthropic)', () => {
|
|
1494
|
+
const messages: BaseMessage[] = [
|
|
1495
|
+
new HumanMessage({ content: [{ type: 'text', text: 'System context' }] }),
|
|
1496
|
+
new AIMessage({ content: [{ type: 'text', text: 'Acknowledged' }] }),
|
|
1497
|
+
new HumanMessage({ content: [{ type: 'text', text: 'User query' }] }),
|
|
1498
|
+
];
|
|
1499
|
+
|
|
1500
|
+
const result = addCacheControl(messages);
|
|
1501
|
+
|
|
1502
|
+
const firstContent = result[0].content as MessageContentComplex[];
|
|
1503
|
+
const lastContent = result[2].content as MessageContentComplex[];
|
|
1504
|
+
|
|
1505
|
+
expect((firstContent[0] as CacheControlBlock).cache_control).toEqual({
|
|
1506
|
+
type: 'ephemeral',
|
|
1507
|
+
});
|
|
1508
|
+
expect((lastContent[0] as CacheControlBlock).cache_control).toEqual({
|
|
1509
|
+
type: 'ephemeral',
|
|
1510
|
+
});
|
|
1511
|
+
});
|
|
1512
|
+
|
|
1513
|
+
it('preserves cache_control through OpenAI message conversion used by OpenRouter', () => {
|
|
1514
|
+
const messages: BaseMessage[] = [
|
|
1515
|
+
new HumanMessage({
|
|
1516
|
+
content: [
|
|
1517
|
+
{
|
|
1518
|
+
type: 'text',
|
|
1519
|
+
text: 'Hello',
|
|
1520
|
+
cache_control: { type: 'ephemeral' },
|
|
1521
|
+
},
|
|
1522
|
+
],
|
|
1523
|
+
}),
|
|
1524
|
+
new AIMessage({ content: 'Hi there' }),
|
|
1525
|
+
new HumanMessage({
|
|
1526
|
+
content: [
|
|
1527
|
+
{
|
|
1528
|
+
type: 'text',
|
|
1529
|
+
text: 'Follow-up',
|
|
1530
|
+
cache_control: { type: 'ephemeral' },
|
|
1531
|
+
},
|
|
1532
|
+
],
|
|
1533
|
+
}),
|
|
1534
|
+
];
|
|
1535
|
+
|
|
1536
|
+
const converted = _convertMessagesToOpenAIParams(messages);
|
|
1537
|
+
|
|
1538
|
+
const firstUserContent = converted[0].content as CacheControlBlock[];
|
|
1539
|
+
const lastUserContent = converted[2].content as CacheControlBlock[];
|
|
1540
|
+
|
|
1541
|
+
expect(firstUserContent[0]).toHaveProperty('cache_control');
|
|
1542
|
+
expect(firstUserContent[0].cache_control).toEqual({ type: 'ephemeral' });
|
|
1543
|
+
expect(lastUserContent[0]).toHaveProperty('cache_control');
|
|
1544
|
+
expect(lastUserContent[0].cache_control).toEqual({ type: 'ephemeral' });
|
|
1545
|
+
});
|
|
1546
|
+
|
|
1547
|
+
it('end-to-end: addCacheControl then convert preserves breakpoints for OpenRouter', () => {
|
|
1548
|
+
const messages: BaseMessage[] = [
|
|
1549
|
+
new HumanMessage({ content: 'First message with context' }),
|
|
1550
|
+
new AIMessage({ content: 'Response' }),
|
|
1551
|
+
new HumanMessage({ content: 'Second question' }),
|
|
1552
|
+
];
|
|
1553
|
+
|
|
1554
|
+
const cached = addCacheControl(messages);
|
|
1555
|
+
const converted = _convertMessagesToOpenAIParams(
|
|
1556
|
+
cached,
|
|
1557
|
+
'anthropic/claude-sonnet-4-20250514'
|
|
1558
|
+
);
|
|
1559
|
+
|
|
1560
|
+
const firstUser = converted[0];
|
|
1561
|
+
const lastUser = converted[2];
|
|
1562
|
+
|
|
1563
|
+
expect(Array.isArray(firstUser.content)).toBe(true);
|
|
1564
|
+
expect(
|
|
1565
|
+
(firstUser.content as CacheControlBlock[])[0]
|
|
1566
|
+
).toHaveProperty('cache_control');
|
|
1567
|
+
|
|
1568
|
+
expect(Array.isArray(lastUser.content)).toBe(true);
|
|
1569
|
+
expect(
|
|
1570
|
+
(lastUser.content as CacheControlBlock[])[0]
|
|
1571
|
+
).toHaveProperty('cache_control');
|
|
1572
|
+
});
|
|
1573
|
+
|
|
1574
|
+
it('strips Bedrock cache before applying OpenRouter/Anthropic cache', () => {
|
|
1575
|
+
const messages: TestMsg[] = [
|
|
1576
|
+
{
|
|
1577
|
+
role: 'user',
|
|
1578
|
+
content: [
|
|
1579
|
+
{ type: ContentTypes.TEXT, text: 'First message' },
|
|
1580
|
+
{ cachePoint: { type: 'default' } },
|
|
1581
|
+
],
|
|
1582
|
+
},
|
|
1583
|
+
{
|
|
1584
|
+
role: 'assistant',
|
|
1585
|
+
content: [
|
|
1586
|
+
{ type: ContentTypes.TEXT, text: 'Response' },
|
|
1587
|
+
{ cachePoint: { type: 'default' } },
|
|
1588
|
+
],
|
|
1589
|
+
},
|
|
1590
|
+
{
|
|
1591
|
+
role: 'user',
|
|
1592
|
+
content: [{ type: ContentTypes.TEXT, text: 'Follow-up' }],
|
|
1593
|
+
},
|
|
1594
|
+
];
|
|
1595
|
+
|
|
1596
|
+
/** @ts-expect-error - Testing cross-provider compatibility */
|
|
1597
|
+
const result = addCacheControl(messages);
|
|
1598
|
+
|
|
1599
|
+
for (const msg of result) {
|
|
1600
|
+
if (Array.isArray(msg.content)) {
|
|
1601
|
+
expect(
|
|
1602
|
+
(msg.content as MessageContentComplex[]).some(
|
|
1603
|
+
(b) => 'cachePoint' in b
|
|
1604
|
+
)
|
|
1605
|
+
).toBe(false);
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
const lastContent = result[2].content as MessageContentComplex[];
|
|
1610
|
+
expect('cache_control' in lastContent[0]).toBe(true);
|
|
1611
|
+
});
|
|
1612
|
+
});
|