Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@librechat/agents",
"version": "3.1.82",
"version": "3.1.83",
"main": "./dist/cjs/main.cjs",
"module": "./dist/esm/main.mjs",
"types": "./dist/types/index.d.ts",
Expand Down
97 changes: 70 additions & 27 deletions src/agents/AgentContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@ import {
Providers,
} from '@/common';
import { createSchemaOnlyTools } from '@/tools/schema';
import { addCacheControl } from '@/messages/cache';
import {
addCacheControl,
addCacheControlToStablePrefixMessages,
} from '@/messages/cache';
import { DEFAULT_RESERVE_RATIO } from '@/messages';
import { toJsonSchema } from '@/utils/schema';

Expand Down Expand Up @@ -584,24 +587,24 @@ export class AgentContext {
}

const promptCacheProvider = this.getPromptCacheProvider();
const shouldMoveOpenRouterDynamicInstructions =
promptCacheProvider === Providers.OPENROUTER &&
const shouldMoveDynamicInstructions =
promptCacheProvider != null &&
stableInstructions !== '' &&
dynamicInstructions !== '';
const systemMessage = this.buildSystemMessage({
stableInstructions,
dynamicInstructions,
promptCacheProvider,
shouldMoveDynamicInstructions,
});

if (this.tokenCounter) {
this.systemMessageTokens = systemMessage
? this.tokenCounter(systemMessage)
: 0;
this.dynamicInstructionTokens =
shouldMoveOpenRouterDynamicInstructions
? this.tokenCounter(new HumanMessage(dynamicInstructions))
: 0;
this.dynamicInstructionTokens = shouldMoveDynamicInstructions
? this.tokenCounter(new HumanMessage(dynamicInstructions))
: 0;
}

return RunnableLambda.from((messages: BaseMessage[]) => {
Expand All @@ -616,16 +619,20 @@ export class AgentContext {
this.summaryText !== '';

const bodyWithSummary =
hasSummaryBody && promptCacheProvider !== Providers.OPENROUTER
hasSummaryBody && promptCacheProvider == null
? [this.buildSummaryHumanMessage(promptCacheProvider), ...messages]
: messages;
const dynamicTail = this.buildOpenRouterDynamicTail({
const dynamicTail = this.buildPromptCacheDynamicTail({
dynamicInstructions,
hasSummaryBody,
promptCacheProvider,
shouldMoveOpenRouterDynamicInstructions,
shouldMoveDynamicInstructions,
});
let body = this.insertAfterFirstMessage(bodyWithSummary, dynamicTail);
let body = this.buildBodyWithPromptCacheDynamicTail(
bodyWithSummary,
dynamicTail,
promptCacheProvider
);

if (
promptCacheProvider != null &&
Expand Down Expand Up @@ -662,45 +669,82 @@ export class AgentContext {
});
}

private buildOpenRouterDynamicTail({
private buildPromptCacheDynamicTail({
dynamicInstructions,
hasSummaryBody,
promptCacheProvider,
shouldMoveOpenRouterDynamicInstructions,
shouldMoveDynamicInstructions,
}: {
dynamicInstructions: string;
hasSummaryBody: boolean;
promptCacheProvider: PromptCacheProvider | undefined;
shouldMoveOpenRouterDynamicInstructions: boolean;
shouldMoveDynamicInstructions: boolean;
}): BaseMessage[] {
if (promptCacheProvider !== Providers.OPENROUTER) {
if (promptCacheProvider == null) {
return [];
}

const dynamicTail = shouldMoveOpenRouterDynamicInstructions
const dynamicTail = shouldMoveDynamicInstructions
? [new HumanMessage(dynamicInstructions)]
: [];

if (!hasSummaryBody) {
return dynamicTail;
}

return [...dynamicTail, this.buildSummaryHumanMessage(promptCacheProvider)];
return [...dynamicTail, this.buildSummaryHumanMessage(undefined)];
}

private insertAfterFirstMessage(
private buildBodyWithPromptCacheDynamicTail(
messages: BaseMessage[],
tail: BaseMessage[]
tail: BaseMessage[],
promptCacheProvider: PromptCacheProvider | undefined
): BaseMessage[] {
if (tail.length === 0) {
return messages;
}

if (messages.length === 0) {
return tail;
const tailIndex = this.getPromptCacheDynamicTailIndex(
messages,
promptCacheProvider
);
const stablePrefix = messages.slice(0, tailIndex);
const trailingMessages = messages.slice(tailIndex);
const cacheablePrefix = this.addStablePromptCacheMarkers(stablePrefix);

return [...cacheablePrefix, ...tail, ...trailingMessages];
}

private getPromptCacheDynamicTailIndex(
messages: BaseMessage[],
promptCacheProvider: PromptCacheProvider | undefined
): number {
const lastIndex = messages.length - 1;

if (lastIndex < 0) {
return 0;
}

if (promptCacheProvider === Providers.OPENROUTER && messages.length === 1) {
return messages.length;
}

if (messages[lastIndex].getType() === 'human') {
return lastIndex;
}

return messages.length;
}

private addStablePromptCacheMarkers(messages: BaseMessage[]): BaseMessage[] {
if (messages.length <= 1) {
return messages;
}

return [messages[0], ...tail, ...messages.slice(1)];
return [
messages[0],
...addCacheControlToStablePrefixMessages(messages.slice(1), 2),
];
}

private getPromptCacheProvider(): PromptCacheProvider | undefined {
Expand Down Expand Up @@ -739,10 +783,12 @@ export class AgentContext {
stableInstructions,
dynamicInstructions,
promptCacheProvider,
shouldMoveDynamicInstructions,
}: {
stableInstructions: string;
dynamicInstructions: string;
promptCacheProvider: PromptCacheProvider | undefined;
shouldMoveDynamicInstructions: boolean;
}): SystemMessage | undefined {
if (!stableInstructions && !dynamicInstructions) {
return undefined;
Expand All @@ -757,16 +803,13 @@ export class AgentContext {
cache_control: { type: 'ephemeral' },
});
}
if (dynamicInstructions) {
if (dynamicInstructions && !shouldMoveDynamicInstructions) {
content.push({ type: 'text', text: dynamicInstructions });
}
return new SystemMessage({ content } as BaseMessageFields);
}

if (
promptCacheProvider === Providers.OPENROUTER &&
!stableInstructions
) {
if (promptCacheProvider === Providers.OPENROUTER && !stableInstructions) {
return new SystemMessage(dynamicInstructions);
}

Expand Down
4 changes: 0 additions & 4 deletions src/agents/__tests__/AgentContext.anthropic.live.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,10 +77,6 @@ describeIfLive('AgentContext Anthropic prompt cache live API', () => {
text: stableInstructions,
cache_control: { type: 'ephemeral' },
},
{
type: 'text',
text: firstDynamicInstructions,
},
],
});

Expand Down
128 changes: 128 additions & 0 deletions src/agents/__tests__/AgentContext.openrouter.live.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
// src/agents/__tests__/AgentContext.openrouter.live.test.ts
/**
* Live OpenRouter prompt-cache verification.
*
* Run with:
* RUN_OPENROUTER_PROMPT_CACHE_LIVE_TESTS=1 OPENROUTER_API_KEY=... npm test -- AgentContext.openrouter.live.test.ts --runInBand
*/
import { config as dotenvConfig } from 'dotenv';
dotenvConfig({ path: process.env.DOTENV_CONFIG_PATH ?? '.env' });

import { describe, expect, it } from '@jest/globals';
import type { ClientOptions } from '@langchain/openai';
import {
runLiveTurn,
assertSystemPayloadShape,
buildDynamicInstructions,
buildStableInstructions,
waitForCachePropagation,
} from './promptCacheLiveHelpers';
import type { ChatOpenRouterInput } from '@/llm/openrouter';
import { Providers } from '@/common';

const apiKey = process.env.OPENROUTER_API_KEY ?? process.env.OPENROUTER_KEY;
const shouldRunLive =
process.env.RUN_OPENROUTER_PROMPT_CACHE_LIVE_TESTS === '1' &&
apiKey != null &&
apiKey !== '';

const describeIfLive = shouldRunLive ? describe : describe.skip;

const model =
process.env.OPENROUTER_PROMPT_CACHE_MODEL ?? 'anthropic/claude-sonnet-4.6';
const providerLabel = 'OpenRouter';
type OpenRouterLiveClientOptions = ChatOpenRouterInput & {
configuration?: ClientOptions;
};

function createClientOptions(): OpenRouterLiveClientOptions {
if (apiKey == null || apiKey === '') {
throw new Error('OPENROUTER_API_KEY is required');
}

const reasoning = model.startsWith('google/gemini-3')
? { max_tokens: 16 }
: undefined;

return {
model,
apiKey,
temperature: 0,
maxTokens: 256,
streaming: true,
streamUsage: true,
promptCache: true,
configuration: {
baseURL:
process.env.OPENROUTER_BASE_URL ?? 'https://openrouter.ai/api/v1',
defaultHeaders: {
'HTTP-Referer': 'https://librechat.ai',
'X-Title': 'LibreChat OpenRouter Prompt Cache Live Test',
},
},
...(reasoning != null ? { reasoning } : {}),
};
}

describeIfLive('AgentContext OpenRouter prompt cache live API', () => {
it('keeps dynamic instructions outside the cached system prefix', async () => {
const nonce = `agent-openrouter-cache-live-${Date.now()}`;
const clientOptions = createClientOptions();
const stableInstructions = buildStableInstructions({
nonce,
providerLabel,
});
const firstDynamicInstructions = buildDynamicInstructions({
marker: 'alpha',
tailDescription:
'The Dynamic Marker line is runtime context and must remain outside the cached prefix.',
});
const secondDynamicInstructions = buildDynamicInstructions({
marker: 'bravo',
tailDescription:
'The Dynamic Marker line is runtime context and must remain outside the cached prefix.',
});

await assertSystemPayloadShape({
agentId: 'live-openrouter-cache-shape-check',
provider: Providers.OPENROUTER,
clientOptions,
stableInstructions,
dynamicInstructions: firstDynamicInstructions,
expectedContent: [
{
type: 'text',
text: stableInstructions,
cache_control: { type: 'ephemeral' },
},
],
});

const first = await runLiveTurn({
provider: Providers.OPENROUTER,
providerLabel,
clientOptions,
runId: `${nonce}-first`,
threadId: `${nonce}-thread`,
stableInstructions,
dynamicInstructions: firstDynamicInstructions,
});

expect(first.text.toLowerCase()).toContain('alpha');

await waitForCachePropagation();

const second = await runLiveTurn({
provider: Providers.OPENROUTER,
providerLabel,
clientOptions,
runId: `${nonce}-second`,
threadId: `${nonce}-thread`,
stableInstructions,
dynamicInstructions: secondDynamicInstructions,
});

expect(second.text.toLowerCase()).toContain('bravo');
expect(second.usage.input_token_details?.cache_read).toBeGreaterThan(0);
}, 120_000);
});
Loading
Loading