Skip to content
Open
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: 3 additions & 1 deletion src/graphs/Graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1071,7 +1071,9 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
| t.BedrockAnthropicClientOptions
| undefined;
if (bedrockOptions?.promptCache === true) {
finalMessages = addBedrockCacheControl<BaseMessage>(finalMessages);
finalMessages = addBedrockCacheControl<BaseMessage>(finalMessages, {
ttl: bedrockOptions.promptCacheTtl,
});
}
}

Expand Down
33 changes: 33 additions & 0 deletions src/llm/bedrock/llm.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -711,6 +711,39 @@ describe('convertToConverseMessages', () => {
expect(converseMessages[0].content).toEqual([{ text: 'Hello!' }]);
});

test('should preserve Bedrock cachePoint TTL on message content', () => {
const { converseMessages } = convertToConverseMessages([
new HumanMessage({
content: [
{ type: 'text', text: 'Cache this prompt.' },
{ cachePoint: { type: 'default', ttl: '1h' } },
] as any,
}),
]);

expect(converseMessages[0].content).toEqual([
{ text: 'Cache this prompt.' },
{ cachePoint: { type: 'default', ttl: '1h' } },
]);
});

test('should preserve Bedrock cachePoint TTL on system content', () => {
const { converseSystem } = convertToConverseMessages([
new SystemMessage({
content: [
{ type: 'text', text: 'Long-lived system prompt.' },
{ cachePoint: { type: 'default', ttl: '1h' } },
] as any,
}),
new HumanMessage('Hello!'),
]);

expect(converseSystem).toEqual([
{ text: 'Long-lived system prompt.' },
{ cachePoint: { type: 'default', ttl: '1h' } },
]);
});

test('should handle standard v1 format with tool_call blocks (e.g., from Anthropic provider)', () => {
const { converseMessages, converseSystem } = convertToConverseMessages([
new SystemMessage("You're an advanced AI assistant."),
Expand Down
67 changes: 39 additions & 28 deletions src/llm/bedrock/utils/message_inputs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ import type {
MessageContentReasoningBlock,
} from '../types';

type BedrockPromptCacheTtl = '5m' | '1h';
type BedrockCachePointBlock = { type: 'default'; ttl?: BedrockPromptCacheTtl };

/**
* Convert a LangChain reasoning block to a Bedrock reasoning block.
*/
Expand Down Expand Up @@ -392,23 +395,32 @@ const standardContentBlockConverter: StandardContentBlockConverter<{
};

/**
* Check if a block has a cache point.
* Check if a block has a cache point and return its normalized form (or undefined).
*/
function isDefaultCachePoint(block: unknown): boolean {
function getDefaultCachePoint(
block: unknown
): BedrockCachePointBlock | undefined {
if (typeof block !== 'object' || block === null) {
return false;
return undefined;
}
if (!('cachePoint' in block)) {
return false;
return undefined;
}
const cachePoint = (block as { cachePoint?: unknown }).cachePoint;
if (typeof cachePoint !== 'object' || cachePoint === null) {
return false;
return undefined;
}
if (!('type' in cachePoint)) {
return false;
return undefined;
}
return (cachePoint as { type?: string }).type === 'default';
if ((cachePoint as { type?: string }).type !== 'default') {
return undefined;
}

const ttl = (cachePoint as { ttl?: unknown }).ttl;
return ttl === '5m' || ttl === '1h'
? { type: 'default', ttl }
: { type: 'default' };
}

/**
Expand Down Expand Up @@ -534,11 +546,10 @@ function convertLangChainContentBlockToConverseContentBlock({
} as BedrockContentBlock;
}

if (isDefaultCachePoint(block)) {
const cachePoint = getDefaultCachePoint(block);
if (cachePoint != null) {
return {
cachePoint: {
type: 'default',
},
cachePoint,
} as BedrockContentBlock;
}

Expand Down Expand Up @@ -568,14 +579,14 @@ function convertSystemMessageToConverseMessage(
contentBlocks.push({
text: (block as { text: string }).text,
});
} else if (isDefaultCachePoint(block)) {
} else {
const cachePoint = getDefaultCachePoint(block);
if (cachePoint == null) {
break;
}
contentBlocks.push({
cachePoint: {
type: 'default',
},
cachePoint,
} as BedrockSystemContentBlock);
} else {
break;
}
}
if (msg.content.length === contentBlocks.length) {
Expand Down Expand Up @@ -638,19 +649,19 @@ function convertAIMessageToConverseMessage(msg: BaseMessage): BedrockMessage {
block as MessageContentReasoningBlock
),
} as BedrockContentBlock);
} else if (isDefaultCachePoint(block)) {
} else {
const cachePoint = getDefaultCachePoint(block);
if (cachePoint == null) {
const blockValues = Object.fromEntries(
Object.entries(block).filter(([key]) => key !== 'type')
);
throw new Error(
`Unsupported content block type: ${block.type} with content of ${JSON.stringify(blockValues, null, 2)}`
);
}
contentBlocks.push({
cachePoint: {
type: 'default',
},
cachePoint,
} as BedrockContentBlock);
} else {
const blockValues = Object.fromEntries(
Object.entries(block).filter(([key]) => key !== 'type')
);
throw new Error(
`Unsupported content block type: ${block.type} with content of ${JSON.stringify(blockValues, null, 2)}`
);
}
});

Expand Down
20 changes: 20 additions & 0 deletions src/messages/cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,26 @@ describe('addBedrockCacheControl (Bedrock cache checkpoints)', () => {
expect(last[1]).toEqual({ cachePoint: { type: 'default' } });
});

it('adds a 1-hour cachePoint TTL when configured', () => {
const messages: TestMsg[] = [
{ role: 'user', content: 'Hello' },
{ role: 'assistant', content: 'Hi' },
];
const result = addBedrockCacheControl(messages, { ttl: '1h' });
const last = result[1].content as MessageContentComplex[];
expect(last[1]).toEqual({ cachePoint: { type: 'default', ttl: '1h' } });
});

it('keeps explicit 5-minute cachePoint TTL when configured', () => {
const messages: TestMsg[] = [
{ role: 'user', content: 'Hello' },
{ role: 'assistant', content: 'Hi' },
];
const result = addBedrockCacheControl(messages, { ttl: '5m' });
const last = result[1].content as MessageContentComplex[];
expect(last[1]).toEqual({ cachePoint: { type: 'default', ttl: '5m' } });
});

it('inserts cachePoint after the last text when multiple text blocks exist', () => {
const messages: TestMsg[] = [
{
Expand Down
30 changes: 25 additions & 5 deletions src/messages/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ type MessageContentWithCacheControl = MessageContentComplex & {
cache_control?: unknown;
};

type BedrockPromptCacheTtl = '5m' | '1h';

type BedrockCacheControlOptions = {
ttl?: BedrockPromptCacheTtl;
};

/**
* Deep clones a message's content to prevent mutation of the original.
*/
Expand Down Expand Up @@ -240,6 +246,18 @@ function isCachePoint(block: MessageContentComplex): boolean {
return 'cachePoint' in block && !('type' in block);
}

function createBedrockCachePoint(
ttl?: BedrockPromptCacheTtl
): MessageContentComplex {
const cachePoint: { type: 'default'; ttl?: BedrockPromptCacheTtl } = {
type: 'default',
};
if (ttl != null) {
cachePoint.ttl = ttl;
}
return { cachePoint } as MessageContentComplex;
}

/**
* Checks if a message's content has Anthropic cache_control fields.
*/
Expand Down Expand Up @@ -339,7 +357,7 @@ export function stripBedrockCacheControl<T extends MessageWithContent>(
*/
export function addBedrockCacheControl<
T extends MessageWithContent & { getType?: () => string; role?: string },
>(messages: T[]): T[] {
>(messages: T[], options: BedrockCacheControlOptions = {}): T[] {
if (!Array.isArray(messages) || messages.length < 2) {
return messages;
}
Expand Down Expand Up @@ -417,15 +435,17 @@ export function addBedrockCacheControl<
// Insert cache point after the last non-empty text block.
// Skip if no cacheable text content exists (whitespace-only messages).
if (needsCacheAdd && lastNonEmptyTextIndex >= 0) {
workingContent.splice(lastNonEmptyTextIndex + 1, 0, {
cachePoint: { type: 'default' },
} as MessageContentComplex);
workingContent.splice(
lastNonEmptyTextIndex + 1,
0,
createBedrockCachePoint(options.ttl)
);
messagesModified++;
}
} else if (typeof content === 'string' && needsCacheAdd) {
workingContent = [
{ type: ContentTypes.TEXT, text: content },
{ cachePoint: { type: 'default' } } as MessageContentComplex,
createBedrockCachePoint(options.ttl),
];
messagesModified++;
} else {
Expand Down
3 changes: 2 additions & 1 deletion src/types/llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,8 +86,9 @@ export type BedrockAnthropicInput = ChatBedrockConverseInput & {
additionalModelRequestFields?: ChatBedrockConverseInput['additionalModelRequestFields'] &
AnthropicReasoning;
promptCache?: boolean;
promptCacheTtl?: '5m' | '1h';
};
export type BedrockConverseClientOptions = ChatBedrockConverseInput;
export type BedrockConverseClientOptions = BedrockAnthropicInput;
export type BedrockAnthropicClientOptions = BedrockAnthropicInput;
export type GoogleClientOptions = GoogleGenerativeAIChatInput & {
customHeaders?: RequestOptions['customHeaders'];
Expand Down