@librechat/agents 3.1.81 → 3.1.83
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 +125 -36
- 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/main.cjs +1 -0
- package/dist/cjs/main.cjs.map +1 -1
- package/dist/cjs/messages/cache.cjs +96 -0
- package/dist/cjs/messages/cache.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 +125 -36
- 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/main.mjs +1 -1
- package/dist/esm/messages/cache.mjs +96 -1
- package/dist/esm/messages/cache.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 +8 -1
- package/dist/types/agents/__tests__/promptCacheLiveHelpers.d.ts +6 -2
- package/dist/types/llm/openrouter/index.d.ts +1 -0
- package/dist/types/llm/openrouter/toolCache.d.ts +2 -0
- package/dist/types/messages/cache.d.ts +1 -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 +191 -40
- package/src/agents/__tests__/AgentContext.anthropic.live.test.ts +0 -4
- package/src/agents/__tests__/AgentContext.openrouter.live.test.ts +128 -0
- package/src/agents/__tests__/AgentContext.test.ts +355 -18
- package/src/agents/__tests__/promptCacheLiveHelpers.ts +8 -2
- 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/messages/cache.ts +143 -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
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
|
+
});
|
package/src/messages/cache.ts
CHANGED
|
@@ -240,6 +240,149 @@ function isCachePoint(block: MessageContentComplex): boolean {
|
|
|
240
240
|
return 'cachePoint' in block && !('type' in block);
|
|
241
241
|
}
|
|
242
242
|
|
|
243
|
+
function getMessageRole(message: MessageWithContent): string | undefined {
|
|
244
|
+
if (message instanceof BaseMessage) {
|
|
245
|
+
return message.getType();
|
|
246
|
+
}
|
|
247
|
+
if ('role' in message && typeof message.role === 'string') {
|
|
248
|
+
return message.role;
|
|
249
|
+
}
|
|
250
|
+
return undefined;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function isCacheableConversationMessage(message: MessageWithContent): boolean {
|
|
254
|
+
const role = getMessageRole(message);
|
|
255
|
+
return (
|
|
256
|
+
role === 'human' || role === 'user' || role === 'ai' || role === 'assistant'
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function isAssistantConversationMessage(message: MessageWithContent): boolean {
|
|
261
|
+
const role = getMessageRole(message);
|
|
262
|
+
return role === 'ai' || role === 'assistant';
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function hasCacheMarker(message: MessageWithContent): boolean {
|
|
266
|
+
return (
|
|
267
|
+
Array.isArray(message.content) &&
|
|
268
|
+
message.content.some((block) => 'cache_control' in block)
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function addCacheControlToRecentMessages<
|
|
273
|
+
T extends AnthropicMessage | BaseMessage,
|
|
274
|
+
>(
|
|
275
|
+
messages: T[],
|
|
276
|
+
maxCachePoints: number,
|
|
277
|
+
canUseMessage: (message: MessageWithContent) => boolean
|
|
278
|
+
): T[] {
|
|
279
|
+
if (
|
|
280
|
+
!Array.isArray(messages) ||
|
|
281
|
+
messages.length === 0 ||
|
|
282
|
+
maxCachePoints <= 0
|
|
283
|
+
) {
|
|
284
|
+
return messages;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const updatedMessages: T[] = [...messages];
|
|
288
|
+
let cachePointsAdded = 0;
|
|
289
|
+
|
|
290
|
+
for (let i = updatedMessages.length - 1; i >= 0; i--) {
|
|
291
|
+
const originalMessage = updatedMessages[i];
|
|
292
|
+
const content = originalMessage.content;
|
|
293
|
+
const hasArrayContent = Array.isArray(content);
|
|
294
|
+
const canAddCache =
|
|
295
|
+
cachePointsAdded < maxCachePoints && canUseMessage(originalMessage);
|
|
296
|
+
|
|
297
|
+
if (!canAddCache && !hasArrayContent) {
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
let workingContent: MessageContentComplex[];
|
|
302
|
+
let modified = false;
|
|
303
|
+
|
|
304
|
+
if (hasArrayContent) {
|
|
305
|
+
const src = content as MessageContentComplex[];
|
|
306
|
+
workingContent = [];
|
|
307
|
+
let lastNonEmptyTextIndex = -1;
|
|
308
|
+
|
|
309
|
+
for (let j = 0; j < src.length; j++) {
|
|
310
|
+
const block = src[j];
|
|
311
|
+
if (isCachePoint(block)) {
|
|
312
|
+
modified = true;
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const cloned = { ...block };
|
|
317
|
+
if ('cache_control' in cloned) {
|
|
318
|
+
delete (cloned as Record<string, unknown>).cache_control;
|
|
319
|
+
modified = true;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if ('type' in cloned && cloned.type === 'text') {
|
|
323
|
+
const text = (cloned as { text?: string }).text;
|
|
324
|
+
if (text != null && text.trim() !== '') {
|
|
325
|
+
lastNonEmptyTextIndex = workingContent.length;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
workingContent.push(cloned as MessageContentComplex);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (canAddCache && lastNonEmptyTextIndex >= 0) {
|
|
332
|
+
(
|
|
333
|
+
workingContent[lastNonEmptyTextIndex] as Anthropic.TextBlockParam
|
|
334
|
+
).cache_control = {
|
|
335
|
+
type: 'ephemeral',
|
|
336
|
+
};
|
|
337
|
+
cachePointsAdded++;
|
|
338
|
+
modified = true;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (!modified) {
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
} else if (
|
|
345
|
+
typeof content === 'string' &&
|
|
346
|
+
content.trim() !== '' &&
|
|
347
|
+
canAddCache
|
|
348
|
+
) {
|
|
349
|
+
workingContent = [
|
|
350
|
+
{ type: 'text', text: content, cache_control: { type: 'ephemeral' } },
|
|
351
|
+
] as unknown as MessageContentComplex[];
|
|
352
|
+
cachePointsAdded++;
|
|
353
|
+
} else {
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
updatedMessages[i] = cloneMessage(
|
|
358
|
+
originalMessage as MessageWithContent,
|
|
359
|
+
workingContent
|
|
360
|
+
) as T;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
return updatedMessages;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
export function addCacheControlToStablePrefixMessages<
|
|
367
|
+
T extends AnthropicMessage | BaseMessage,
|
|
368
|
+
>(messages: T[], maxCachePoints: number): T[] {
|
|
369
|
+
const assistantMarked = addCacheControlToRecentMessages(
|
|
370
|
+
messages,
|
|
371
|
+
maxCachePoints,
|
|
372
|
+
isAssistantConversationMessage
|
|
373
|
+
);
|
|
374
|
+
|
|
375
|
+
if (assistantMarked.some(hasCacheMarker)) {
|
|
376
|
+
return assistantMarked;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
return addCacheControlToRecentMessages(
|
|
380
|
+
messages,
|
|
381
|
+
maxCachePoints,
|
|
382
|
+
isCacheableConversationMessage
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
|
|
243
386
|
/**
|
|
244
387
|
* Checks if a message's content has Anthropic cache_control fields.
|
|
245
388
|
*/
|