@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.
Files changed (60) hide show
  1. package/dist/cjs/agents/AgentContext.cjs +125 -36
  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/main.cjs +1 -0
  12. package/dist/cjs/main.cjs.map +1 -1
  13. package/dist/cjs/messages/cache.cjs +96 -0
  14. package/dist/cjs/messages/cache.cjs.map +1 -1
  15. package/dist/cjs/tools/ToolNode.cjs +70 -12
  16. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  17. package/dist/esm/agents/AgentContext.mjs +125 -36
  18. package/dist/esm/agents/AgentContext.mjs.map +1 -1
  19. package/dist/esm/graphs/Graph.mjs +13 -0
  20. package/dist/esm/graphs/Graph.mjs.map +1 -1
  21. package/dist/esm/llm/openai/index.mjs +50 -14
  22. package/dist/esm/llm/openai/index.mjs.map +1 -1
  23. package/dist/esm/llm/openrouter/index.mjs +17 -7
  24. package/dist/esm/llm/openrouter/index.mjs.map +1 -1
  25. package/dist/esm/llm/openrouter/toolCache.mjs +53 -0
  26. package/dist/esm/llm/openrouter/toolCache.mjs.map +1 -0
  27. package/dist/esm/main.mjs +1 -1
  28. package/dist/esm/messages/cache.mjs +96 -1
  29. package/dist/esm/messages/cache.mjs.map +1 -1
  30. package/dist/esm/tools/ToolNode.mjs +70 -12
  31. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  32. package/dist/types/agents/AgentContext.d.ts +8 -1
  33. package/dist/types/agents/__tests__/promptCacheLiveHelpers.d.ts +6 -2
  34. package/dist/types/llm/openrouter/index.d.ts +1 -0
  35. package/dist/types/llm/openrouter/toolCache.d.ts +2 -0
  36. package/dist/types/messages/cache.d.ts +1 -0
  37. package/dist/types/tools/ToolNode.d.ts +5 -0
  38. package/dist/types/types/run.d.ts +2 -0
  39. package/package.json +2 -1
  40. package/src/agents/AgentContext.ts +191 -40
  41. package/src/agents/__tests__/AgentContext.anthropic.live.test.ts +0 -4
  42. package/src/agents/__tests__/AgentContext.openrouter.live.test.ts +128 -0
  43. package/src/agents/__tests__/AgentContext.test.ts +355 -18
  44. package/src/agents/__tests__/promptCacheLiveHelpers.ts +8 -2
  45. package/src/graphs/Graph.ts +24 -0
  46. package/src/llm/custom-chat-models.smoke.test.ts +76 -0
  47. package/src/llm/openai/deepseek.test.ts +14 -1
  48. package/src/llm/openai/index.ts +38 -12
  49. package/src/llm/openrouter/index.ts +22 -7
  50. package/src/llm/openrouter/reasoning.test.ts +33 -0
  51. package/src/llm/openrouter/toolCache.test.ts +83 -0
  52. package/src/llm/openrouter/toolCache.ts +89 -0
  53. package/src/messages/cache.test.ts +127 -0
  54. package/src/messages/cache.ts +143 -0
  55. package/src/scripts/openrouter_prompt_cache_live.ts +310 -0
  56. package/src/specs/agent-handoffs.live.test.ts +140 -0
  57. package/src/specs/agent-handoffs.test.ts +266 -2
  58. package/src/specs/openrouter.simple.test.ts +15 -8
  59. package/src/tools/ToolNode.ts +92 -13
  60. package/src/types/run.ts +2 -0
@@ -54,6 +54,8 @@ export declare class AgentContext {
54
54
  tokenCounter?: t.TokenCounter;
55
55
  /** Token count for the system message (instructions text). */
56
56
  systemMessageTokens: number;
57
+ /** Token count for instruction text emitted outside the system message. */
58
+ dynamicInstructionTokens: number;
57
59
  /** Token count for tool schemas only. */
58
60
  toolSchemaTokens: number;
59
61
  /** Running calibration ratio from the pruner — persisted across runs via contextMeta. */
@@ -224,7 +226,12 @@ export declare class AgentContext {
224
226
  * Only called when content has actually changed.
225
227
  */
226
228
  private buildSystemRunnable;
227
- private hasAnthropicPromptCache;
229
+ private buildSummaryHumanMessage;
230
+ private buildPromptCacheDynamicTail;
231
+ private buildBodyWithPromptCacheDynamicTail;
232
+ private getPromptCacheDynamicTailIndex;
233
+ private addStablePromptCacheMarkers;
234
+ private getPromptCacheProvider;
228
235
  private hasBedrockPromptCache;
229
236
  private buildSystemMessage;
230
237
  /**
@@ -1,7 +1,9 @@
1
1
  import type { UsageMetadata } from '@langchain/core/messages';
2
+ import type { ClientOptions } from '@langchain/openai';
2
3
  import type * as t from '@/types';
3
4
  import { Providers } from '@/common';
4
- type LivePromptCacheProvider = Providers.ANTHROPIC | Providers.BEDROCK;
5
+ import type { ChatOpenRouterInput } from '@/llm/openrouter';
6
+ type LivePromptCacheProvider = Providers.ANTHROPIC | Providers.BEDROCK | Providers.OPENROUTER;
5
7
  type PromptCacheExpectedSystemBlock = {
6
8
  type: 'text';
7
9
  text: string;
@@ -13,7 +15,9 @@ type PromptCacheExpectedSystemBlock = {
13
15
  type: 'default';
14
16
  };
15
17
  };
16
- type LivePromptCacheClientOptions = t.ClientOptions | t.BedrockAnthropicClientOptions;
18
+ type LivePromptCacheClientOptions = t.ClientOptions | t.BedrockAnthropicClientOptions | (ChatOpenRouterInput & {
19
+ configuration?: ClientOptions;
20
+ });
17
21
  export declare function buildStableInstructions({ nonce, providerLabel, }: {
18
22
  nonce: string;
19
23
  providerLabel: string;
@@ -15,6 +15,7 @@ export interface ChatOpenRouterCallOptions extends Omit<ChatOpenAICallOptions, '
15
15
  include_reasoning?: boolean;
16
16
  reasoning?: OpenRouterReasoning;
17
17
  modelKwargs?: OpenAIChatInput['modelKwargs'];
18
+ promptCache?: boolean;
18
19
  }
19
20
  export type ChatOpenRouterInput = Partial<ChatOpenRouterCallOptions & OpenAIChatInput>;
20
21
  /** invocationParams return type extended with OpenRouter reasoning */
@@ -0,0 +1,2 @@
1
+ import type { GraphTools } from '@/types';
2
+ export declare function partitionAndMarkOpenRouterToolCache(tools: GraphTools | undefined, isDeferred: (toolName: string) => boolean): GraphTools | undefined;
@@ -13,6 +13,7 @@ type MessageWithContent = {
13
13
  * @returns - A new array of message objects with cache control added.
14
14
  */
15
15
  export declare function addCacheControl<T extends AnthropicMessage | BaseMessage>(messages: T[]): T[];
16
+ export declare function addCacheControlToStablePrefixMessages<T extends AnthropicMessage | BaseMessage>(messages: T[], maxCachePoints: number): T[];
16
17
  /**
17
18
  * Removes all Anthropic cache_control fields from messages
18
19
  * Used when switching from Anthropic to Bedrock provider
@@ -152,6 +152,11 @@ export declare class ToolNode<T = any> extends RunnableCallable<T, T> {
152
152
  * `createLocalCodingToolBundle()` use.
153
153
  */
154
154
  getFileCheckpointer(): t.LocalFileCheckpointer | undefined;
155
+ private getRegisteredHandoffNames;
156
+ private hasRegisteredHandoffTool;
157
+ private getHandoffToolNameSuggestion;
158
+ private shouldHandleUnknownHandoffLocally;
159
+ private getUnknownToolErrorMessage;
155
160
  /**
156
161
  * Flush the per-Run direct-path turn cache. Called by the Graph at
157
162
  * end-of-Run via `clearHeavyState`. The map intentionally survives
@@ -195,6 +195,8 @@ export type TokenBudgetBreakdown = {
195
195
  instructionTokens: number;
196
196
  /** Tokens from the system message text alone. */
197
197
  systemMessageTokens: number;
198
+ /** Tokens from instruction text emitted outside the system message. */
199
+ dynamicInstructionTokens: number;
198
200
  /** Tokens from tool schema definitions. */
199
201
  toolSchemaTokens: number;
200
202
  /** Tokens from the conversation summary. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@librechat/agents",
3
- "version": "3.1.81",
3
+ "version": "3.1.83",
4
4
  "main": "./dist/cjs/main.cjs",
5
5
  "module": "./dist/esm/main.mjs",
6
6
  "types": "./dist/types/index.d.ts",
@@ -171,6 +171,7 @@
171
171
  "start:dev": "node --loader ./tsconfig-paths-bootstrap.mjs --experimental-specifier-resolution=node ./src/main.ts",
172
172
  "supervised": "node -r dotenv/config --loader ./tsconfig-paths-bootstrap.mjs --experimental-specifier-resolution=node ./src/scripts/supervised.ts --provider anthropic --name Jo --location \"New York, NY\"",
173
173
  "test": "NODE_OPTIONS='--experimental-vm-modules' jest",
174
+ "test:live:handoffs": "RUN_HANDOFF_LIVE_TESTS=1 NODE_OPTIONS='--experimental-vm-modules' jest src/specs/agent-handoffs.live.test.ts --runInBand",
174
175
  "test:memory": "NODE_OPTIONS='--expose-gc' npx jest src/specs/title.memory-leak.test.ts",
175
176
  "test:all": "npm test -- --testPathIgnorePatterns=title.memory-leak.test.ts && npm run test:memory",
176
177
  "reinstall": "npm run clean && npm ci && rm -rf ./dist && npm run build",
@@ -16,7 +16,10 @@ import {
16
16
  Providers,
17
17
  } from '@/common';
18
18
  import { createSchemaOnlyTools } from '@/tools/schema';
19
- import { addCacheControl } from '@/messages/cache';
19
+ import {
20
+ addCacheControl,
21
+ addCacheControlToStablePrefixMessages,
22
+ } from '@/messages/cache';
20
23
  import { DEFAULT_RESERVE_RATIO } from '@/messages';
21
24
  import { toJsonSchema } from '@/utils/schema';
22
25
 
@@ -30,6 +33,8 @@ type AgentSystemContentBlock =
30
33
  | AgentSystemTextBlock
31
34
  | { cachePoint: { type: 'default' } };
32
35
 
36
+ type PromptCacheProvider = Providers.ANTHROPIC | Providers.OPENROUTER;
37
+
33
38
  /**
34
39
  * Encapsulates agent-specific state that can vary between agents in a multi-agent system
35
40
  */
@@ -177,6 +182,8 @@ export class AgentContext {
177
182
  tokenCounter?: t.TokenCounter;
178
183
  /** Token count for the system message (instructions text). */
179
184
  systemMessageTokens: number = 0;
185
+ /** Token count for instruction text emitted outside the system message. */
186
+ dynamicInstructionTokens: number = 0;
180
187
  /** Token count for tool schemas only. */
181
188
  toolSchemaTokens: number = 0;
182
189
  /** Running calibration ratio from the pruner — persisted across runs via contextMeta. */
@@ -190,7 +197,12 @@ export class AgentContext {
190
197
  get instructionTokens(): number {
191
198
  const summaryOverhead =
192
199
  this._summaryLocation === 'user_message' ? this.summaryTokenCount : 0;
193
- return this.systemMessageTokens + this.toolSchemaTokens + summaryOverhead;
200
+ return (
201
+ this.systemMessageTokens +
202
+ this.dynamicInstructionTokens +
203
+ this.toolSchemaTokens +
204
+ summaryOverhead
205
+ );
194
206
  }
195
207
  /** The amount of time that should pass before another consecutive API call */
196
208
  streamBuffer?: number;
@@ -570,20 +582,29 @@ export class AgentContext {
570
582
 
571
583
  if (!stableInstructions && !dynamicInstructions && !hasMidRunSummary) {
572
584
  this.systemMessageTokens = 0;
585
+ this.dynamicInstructionTokens = 0;
573
586
  return undefined;
574
587
  }
575
588
 
576
- const usePromptCache = this.hasAnthropicPromptCache();
589
+ const promptCacheProvider = this.getPromptCacheProvider();
590
+ const shouldMoveDynamicInstructions =
591
+ promptCacheProvider != null &&
592
+ stableInstructions !== '' &&
593
+ dynamicInstructions !== '';
577
594
  const systemMessage = this.buildSystemMessage({
578
595
  stableInstructions,
579
596
  dynamicInstructions,
580
- usePromptCache,
597
+ promptCacheProvider,
598
+ shouldMoveDynamicInstructions,
581
599
  });
582
600
 
583
601
  if (this.tokenCounter) {
584
602
  this.systemMessageTokens = systemMessage
585
603
  ? this.tokenCounter(systemMessage)
586
604
  : 0;
605
+ this.dynamicInstructionTokens = shouldMoveDynamicInstructions
606
+ ? this.tokenCounter(new HumanMessage(dynamicInstructions))
607
+ : 0;
587
608
  }
588
609
 
589
610
  return RunnableLambda.from((messages: BaseMessage[]) => {
@@ -597,45 +618,155 @@ export class AgentContext {
597
618
  this.summaryText != null &&
598
619
  this.summaryText !== '';
599
620
 
600
- let body: BaseMessage[];
601
- if (hasSummaryBody) {
602
- const wrappedSummary =
603
- '<summary>\n' +
604
- (this.summaryText as string) +
605
- '\n</summary>\n\n' +
606
- 'This is your own checkpoint: you wrote it to preserve context after compaction. Pick up where you left off based on the summary above. Do not repeat prior tasks, information or acknowledge this checkpoint message directly.';
607
-
608
- const summaryMsg = usePromptCache
609
- ? new HumanMessage({
610
- content: [
611
- {
612
- type: 'text',
613
- text: wrappedSummary,
614
- cache_control: { type: 'ephemeral' },
615
- },
616
- ],
617
- })
618
- : new HumanMessage(wrappedSummary);
619
- body = [summaryMsg, ...messages];
620
- } else {
621
- body = messages;
622
- }
621
+ const bodyWithSummary =
622
+ hasSummaryBody && promptCacheProvider == null
623
+ ? [this.buildSummaryHumanMessage(promptCacheProvider), ...messages]
624
+ : messages;
625
+ const dynamicTail = this.buildPromptCacheDynamicTail({
626
+ dynamicInstructions,
627
+ hasSummaryBody,
628
+ promptCacheProvider,
629
+ shouldMoveDynamicInstructions,
630
+ });
631
+ let body = this.buildBodyWithPromptCacheDynamicTail(
632
+ bodyWithSummary,
633
+ dynamicTail,
634
+ promptCacheProvider
635
+ );
623
636
 
624
- if (usePromptCache && body.length >= 2) {
637
+ if (
638
+ promptCacheProvider != null &&
639
+ dynamicTail.length === 0 &&
640
+ body.length >= 2
641
+ ) {
625
642
  body = addCacheControl(body);
626
643
  }
627
644
  return [...prefix, ...body];
628
645
  }).withConfig({ runName: 'prompt' });
629
646
  }
630
647
 
631
- private hasAnthropicPromptCache(): boolean {
632
- if (this.provider !== Providers.ANTHROPIC) {
633
- return false;
648
+ private buildSummaryHumanMessage(
649
+ promptCacheProvider: PromptCacheProvider | undefined
650
+ ): HumanMessage {
651
+ const wrappedSummary =
652
+ '<summary>\n' +
653
+ (this.summaryText as string) +
654
+ '\n</summary>\n\n' +
655
+ 'This is your own checkpoint: you wrote it to preserve context after compaction. Pick up where you left off based on the summary above. Do not repeat prior tasks, information or acknowledge this checkpoint message directly.';
656
+
657
+ if (promptCacheProvider !== Providers.ANTHROPIC) {
658
+ return new HumanMessage(wrappedSummary);
634
659
  }
635
- const anthropicOptions = this.clientOptions as
636
- | t.AnthropicClientOptions
637
- | undefined;
638
- return anthropicOptions?.promptCache === true;
660
+
661
+ return new HumanMessage({
662
+ content: [
663
+ {
664
+ type: 'text',
665
+ text: wrappedSummary,
666
+ cache_control: { type: 'ephemeral' },
667
+ },
668
+ ],
669
+ });
670
+ }
671
+
672
+ private buildPromptCacheDynamicTail({
673
+ dynamicInstructions,
674
+ hasSummaryBody,
675
+ promptCacheProvider,
676
+ shouldMoveDynamicInstructions,
677
+ }: {
678
+ dynamicInstructions: string;
679
+ hasSummaryBody: boolean;
680
+ promptCacheProvider: PromptCacheProvider | undefined;
681
+ shouldMoveDynamicInstructions: boolean;
682
+ }): BaseMessage[] {
683
+ if (promptCacheProvider == null) {
684
+ return [];
685
+ }
686
+
687
+ const dynamicTail = shouldMoveDynamicInstructions
688
+ ? [new HumanMessage(dynamicInstructions)]
689
+ : [];
690
+
691
+ if (!hasSummaryBody) {
692
+ return dynamicTail;
693
+ }
694
+
695
+ return [...dynamicTail, this.buildSummaryHumanMessage(undefined)];
696
+ }
697
+
698
+ private buildBodyWithPromptCacheDynamicTail(
699
+ messages: BaseMessage[],
700
+ tail: BaseMessage[],
701
+ promptCacheProvider: PromptCacheProvider | undefined
702
+ ): BaseMessage[] {
703
+ if (tail.length === 0) {
704
+ return messages;
705
+ }
706
+
707
+ const tailIndex = this.getPromptCacheDynamicTailIndex(
708
+ messages,
709
+ promptCacheProvider
710
+ );
711
+ const stablePrefix = messages.slice(0, tailIndex);
712
+ const trailingMessages = messages.slice(tailIndex);
713
+ const cacheablePrefix = this.addStablePromptCacheMarkers(stablePrefix);
714
+
715
+ return [...cacheablePrefix, ...tail, ...trailingMessages];
716
+ }
717
+
718
+ private getPromptCacheDynamicTailIndex(
719
+ messages: BaseMessage[],
720
+ promptCacheProvider: PromptCacheProvider | undefined
721
+ ): number {
722
+ const lastIndex = messages.length - 1;
723
+
724
+ if (lastIndex < 0) {
725
+ return 0;
726
+ }
727
+
728
+ if (promptCacheProvider === Providers.OPENROUTER && messages.length === 1) {
729
+ return messages.length;
730
+ }
731
+
732
+ if (messages[lastIndex].getType() === 'human') {
733
+ return lastIndex;
734
+ }
735
+
736
+ return messages.length;
737
+ }
738
+
739
+ private addStablePromptCacheMarkers(messages: BaseMessage[]): BaseMessage[] {
740
+ if (messages.length <= 1) {
741
+ return messages;
742
+ }
743
+
744
+ return [
745
+ messages[0],
746
+ ...addCacheControlToStablePrefixMessages(messages.slice(1), 2),
747
+ ];
748
+ }
749
+
750
+ private getPromptCacheProvider(): PromptCacheProvider | undefined {
751
+ if (this.provider === Providers.ANTHROPIC) {
752
+ const anthropicOptions = this.clientOptions as
753
+ | t.AnthropicClientOptions
754
+ | undefined;
755
+ return anthropicOptions?.promptCache === true
756
+ ? Providers.ANTHROPIC
757
+ : undefined;
758
+ }
759
+
760
+ if (this.provider === Providers.OPENROUTER) {
761
+ const openRouterOptions = this.clientOptions as
762
+ | t.ProviderOptionsMap[Providers.OPENROUTER]
763
+ | undefined;
764
+ return openRouterOptions?.promptCache === true
765
+ ? Providers.OPENROUTER
766
+ : undefined;
767
+ }
768
+
769
+ return undefined;
639
770
  }
640
771
 
641
772
  private hasBedrockPromptCache(): boolean {
@@ -651,17 +782,19 @@ export class AgentContext {
651
782
  private buildSystemMessage({
652
783
  stableInstructions,
653
784
  dynamicInstructions,
654
- usePromptCache,
785
+ promptCacheProvider,
786
+ shouldMoveDynamicInstructions,
655
787
  }: {
656
788
  stableInstructions: string;
657
789
  dynamicInstructions: string;
658
- usePromptCache: boolean;
790
+ promptCacheProvider: PromptCacheProvider | undefined;
791
+ shouldMoveDynamicInstructions: boolean;
659
792
  }): SystemMessage | undefined {
660
793
  if (!stableInstructions && !dynamicInstructions) {
661
794
  return undefined;
662
795
  }
663
796
 
664
- if (usePromptCache) {
797
+ if (promptCacheProvider === Providers.ANTHROPIC) {
665
798
  const content: AgentSystemContentBlock[] = [];
666
799
  if (stableInstructions) {
667
800
  content.push({
@@ -670,12 +803,28 @@ export class AgentContext {
670
803
  cache_control: { type: 'ephemeral' },
671
804
  });
672
805
  }
673
- if (dynamicInstructions) {
806
+ if (dynamicInstructions && !shouldMoveDynamicInstructions) {
674
807
  content.push({ type: 'text', text: dynamicInstructions });
675
808
  }
676
809
  return new SystemMessage({ content } as BaseMessageFields);
677
810
  }
678
811
 
812
+ if (promptCacheProvider === Providers.OPENROUTER && !stableInstructions) {
813
+ return new SystemMessage(dynamicInstructions);
814
+ }
815
+
816
+ if (promptCacheProvider === Providers.OPENROUTER) {
817
+ return new SystemMessage({
818
+ content: [
819
+ {
820
+ type: 'text',
821
+ text: stableInstructions,
822
+ cache_control: { type: 'ephemeral' },
823
+ },
824
+ ],
825
+ } as BaseMessageFields);
826
+ }
827
+
679
828
  if (this.hasBedrockPromptCache() && stableInstructions) {
680
829
  const content: AgentSystemContentBlock[] = [
681
830
  { type: 'text', text: stableInstructions },
@@ -699,6 +848,7 @@ export class AgentContext {
699
848
  */
700
849
  reset(): void {
701
850
  this.systemMessageTokens = 0;
851
+ this.dynamicInstructionTokens = 0;
702
852
  this.toolSchemaTokens = 0;
703
853
  this.cachedSystemRunnable = undefined;
704
854
  this.systemRunnableStale = true;
@@ -1054,6 +1204,7 @@ export class AgentContext {
1054
1204
  maxContextTokens,
1055
1205
  instructionTokens: this.instructionTokens,
1056
1206
  systemMessageTokens: this.systemMessageTokens,
1207
+ dynamicInstructionTokens: this.dynamicInstructionTokens,
1057
1208
  toolSchemaTokens: this.toolSchemaTokens,
1058
1209
  summaryTokens: this.summaryTokenCount,
1059
1210
  toolCount,
@@ -1072,7 +1223,7 @@ export class AgentContext {
1072
1223
  const lines = [
1073
1224
  'Token budget breakdown:',
1074
1225
  ` maxContextTokens: ${b.maxContextTokens}`,
1075
- ` instructionTokens: ${b.instructionTokens} (system: ${b.systemMessageTokens}, tools: ${b.toolSchemaTokens} [${b.toolCount} tools])`,
1226
+ ` instructionTokens: ${b.instructionTokens} (system: ${b.systemMessageTokens}, dynamic: ${b.dynamicInstructionTokens}, tools: ${b.toolSchemaTokens} [${b.toolCount} tools])`,
1076
1227
  ` summaryTokens: ${b.summaryTokens}`,
1077
1228
  ` messageTokens: ${b.messageTokens} (${b.messageCount} messages)`,
1078
1229
  ` availableForMessages: ${b.availableForMessages}`,
@@ -77,10 +77,6 @@ describeIfLive('AgentContext Anthropic prompt cache live API', () => {
77
77
  text: stableInstructions,
78
78
  cache_control: { type: 'ephemeral' },
79
79
  },
80
- {
81
- type: 'text',
82
- text: firstDynamicInstructions,
83
- },
84
80
  ],
85
81
  });
86
82
 
@@ -0,0 +1,128 @@
1
+ // src/agents/__tests__/AgentContext.openrouter.live.test.ts
2
+ /**
3
+ * Live OpenRouter prompt-cache verification.
4
+ *
5
+ * Run with:
6
+ * RUN_OPENROUTER_PROMPT_CACHE_LIVE_TESTS=1 OPENROUTER_API_KEY=... npm test -- AgentContext.openrouter.live.test.ts --runInBand
7
+ */
8
+ import { config as dotenvConfig } from 'dotenv';
9
+ dotenvConfig({ path: process.env.DOTENV_CONFIG_PATH ?? '.env' });
10
+
11
+ import { describe, expect, it } from '@jest/globals';
12
+ import type { ClientOptions } from '@langchain/openai';
13
+ import {
14
+ runLiveTurn,
15
+ assertSystemPayloadShape,
16
+ buildDynamicInstructions,
17
+ buildStableInstructions,
18
+ waitForCachePropagation,
19
+ } from './promptCacheLiveHelpers';
20
+ import type { ChatOpenRouterInput } from '@/llm/openrouter';
21
+ import { Providers } from '@/common';
22
+
23
+ const apiKey = process.env.OPENROUTER_API_KEY ?? process.env.OPENROUTER_KEY;
24
+ const shouldRunLive =
25
+ process.env.RUN_OPENROUTER_PROMPT_CACHE_LIVE_TESTS === '1' &&
26
+ apiKey != null &&
27
+ apiKey !== '';
28
+
29
+ const describeIfLive = shouldRunLive ? describe : describe.skip;
30
+
31
+ const model =
32
+ process.env.OPENROUTER_PROMPT_CACHE_MODEL ?? 'anthropic/claude-sonnet-4.6';
33
+ const providerLabel = 'OpenRouter';
34
+ type OpenRouterLiveClientOptions = ChatOpenRouterInput & {
35
+ configuration?: ClientOptions;
36
+ };
37
+
38
+ function createClientOptions(): OpenRouterLiveClientOptions {
39
+ if (apiKey == null || apiKey === '') {
40
+ throw new Error('OPENROUTER_API_KEY is required');
41
+ }
42
+
43
+ const reasoning = model.startsWith('google/gemini-3')
44
+ ? { max_tokens: 16 }
45
+ : undefined;
46
+
47
+ return {
48
+ model,
49
+ apiKey,
50
+ temperature: 0,
51
+ maxTokens: 256,
52
+ streaming: true,
53
+ streamUsage: true,
54
+ promptCache: true,
55
+ configuration: {
56
+ baseURL:
57
+ process.env.OPENROUTER_BASE_URL ?? 'https://openrouter.ai/api/v1',
58
+ defaultHeaders: {
59
+ 'HTTP-Referer': 'https://librechat.ai',
60
+ 'X-Title': 'LibreChat OpenRouter Prompt Cache Live Test',
61
+ },
62
+ },
63
+ ...(reasoning != null ? { reasoning } : {}),
64
+ };
65
+ }
66
+
67
+ describeIfLive('AgentContext OpenRouter prompt cache live API', () => {
68
+ it('keeps dynamic instructions outside the cached system prefix', async () => {
69
+ const nonce = `agent-openrouter-cache-live-${Date.now()}`;
70
+ const clientOptions = createClientOptions();
71
+ const stableInstructions = buildStableInstructions({
72
+ nonce,
73
+ providerLabel,
74
+ });
75
+ const firstDynamicInstructions = buildDynamicInstructions({
76
+ marker: 'alpha',
77
+ tailDescription:
78
+ 'The Dynamic Marker line is runtime context and must remain outside the cached prefix.',
79
+ });
80
+ const secondDynamicInstructions = buildDynamicInstructions({
81
+ marker: 'bravo',
82
+ tailDescription:
83
+ 'The Dynamic Marker line is runtime context and must remain outside the cached prefix.',
84
+ });
85
+
86
+ await assertSystemPayloadShape({
87
+ agentId: 'live-openrouter-cache-shape-check',
88
+ provider: Providers.OPENROUTER,
89
+ clientOptions,
90
+ stableInstructions,
91
+ dynamicInstructions: firstDynamicInstructions,
92
+ expectedContent: [
93
+ {
94
+ type: 'text',
95
+ text: stableInstructions,
96
+ cache_control: { type: 'ephemeral' },
97
+ },
98
+ ],
99
+ });
100
+
101
+ const first = await runLiveTurn({
102
+ provider: Providers.OPENROUTER,
103
+ providerLabel,
104
+ clientOptions,
105
+ runId: `${nonce}-first`,
106
+ threadId: `${nonce}-thread`,
107
+ stableInstructions,
108
+ dynamicInstructions: firstDynamicInstructions,
109
+ });
110
+
111
+ expect(first.text.toLowerCase()).toContain('alpha');
112
+
113
+ await waitForCachePropagation();
114
+
115
+ const second = await runLiveTurn({
116
+ provider: Providers.OPENROUTER,
117
+ providerLabel,
118
+ clientOptions,
119
+ runId: `${nonce}-second`,
120
+ threadId: `${nonce}-thread`,
121
+ stableInstructions,
122
+ dynamicInstructions: secondDynamicInstructions,
123
+ });
124
+
125
+ expect(second.text.toLowerCase()).toContain('bravo');
126
+ expect(second.usage.input_token_details?.cache_read).toBeGreaterThan(0);
127
+ }, 120_000);
128
+ });