Skip to content
This repository was archived by the owner on Jun 3, 2026. It is now read-only.
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
33 changes: 33 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,39 @@ export interface Message { // Top-level should come first

**Rationale**: This ordering makes files more readable by providing an overview first, then details.

### Discriminated Union Naming Convention

**When creating discriminated unions with a `type` field, the type value MUST match the interface name with the first letter lowercase.**

```typescript
// ✅ Correct - type matches interface name (first letter lowercase)
export interface TextBlock {
type: 'textBlock' // Matches 'TextBlock' interface name
text: string
}

export interface ToolUseBlock {
type: 'toolUseBlock' // Matches 'ToolUseBlock' interface name
name: string
toolUseId: string
}

export interface CachePointBlock {
type: 'cachePointBlock' // Matches 'CachePointBlock' interface name
cacheType: 'default'
}

export type ContentBlock = TextBlock | ToolUseBlock | CachePointBlock

// ❌ Wrong - type doesn't match interface name
export interface CachePointBlock {
type: 'cachePoint' // Should be 'cachePointBlock'
cacheType: 'default'
}
```

**Rationale**: This consistent naming makes discriminated unions predictable and improves code readability. Developers can easily understand the relationship between the type value and the interface.

### Error Handling

```typescript
Expand Down
3 changes: 3 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,11 @@ export type {
ToolUseBlock,
ToolResultBlock,
ReasoningBlock,
CachePointBlock,
ContentBlock,
Message,
SystemPrompt,
SystemContentBlock,
} from './types/messages'

// Tool types
Expand Down
163 changes: 163 additions & 0 deletions src/models/__tests__/bedrock.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,32 @@ describe('BedrockModel', () => {
modelId: expect.any(String),
})
})

it('formats cache point blocks in messages', async () => {
const provider = new BedrockModel()
const messages: Message[] = [
{
role: 'user',
content: [
{ type: 'textBlock', text: 'Message with cache point' },
{ type: 'cachePointBlock', cacheType: 'default' },
],
},
]

collectEvents(provider.stream(messages))

// Verify ConverseStreamCommand was called with properly formatted request
expect(mockConverseStreamCommand).toHaveBeenLastCalledWith({
messages: [
{
role: 'user',
content: [{ text: 'Message with cache point' }, { cachePoint: { type: 'default' } }],
},
],
modelId: expect.any(String),
})
})
})

describe('stream', () => {
Expand Down Expand Up @@ -726,4 +752,141 @@ describe('BedrockModel', () => {
}
})
})

describe('system prompt formatting', async () => {
const { ConverseStreamCommand } = await import('@aws-sdk/client-bedrock-runtime')
const mockConverseStreamCommand = vi.mocked(ConverseStreamCommand)

beforeEach(() => {
vi.clearAllMocks()
})

it('formats string system prompt with cachePrompt config', async () => {
const provider = new BedrockModel({ cachePrompt: 'default' })
const messages: Message[] = [{ role: 'user', content: [{ type: 'textBlock', text: 'Hello' }] }]
const options: StreamOptions = {
systemPrompt: 'You are a helpful assistant',
}

collectEvents(provider.stream(messages, options))

expect(mockConverseStreamCommand).toHaveBeenLastCalledWith({
modelId: 'global.anthropic.claude-sonnet-4-5-20250929-v1:0',
messages: [
{
role: 'user',
content: [{ text: 'Hello' }],
},
],
system: [{ text: 'You are a helpful assistant' }, { cachePoint: { type: 'default' } }],
})
})

it('formats array system prompt with text blocks only', async () => {
const provider = new BedrockModel()
const messages: Message[] = [{ role: 'user', content: [{ type: 'textBlock', text: 'Hello' }] }]
const options: StreamOptions = {
systemPrompt: [
{ type: 'textBlock', text: 'You are a helpful assistant' },
{ type: 'textBlock', text: 'Additional context here' },
],
}

collectEvents(provider.stream(messages, options))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting that this doesn't have to be awaited, though I guess it's the initial call that makes sens


expect(mockConverseStreamCommand).toHaveBeenLastCalledWith({
modelId: 'global.anthropic.claude-sonnet-4-5-20250929-v1:0',
messages: [
{
role: 'user',
content: [{ text: 'Hello' }],
},
],
system: [{ text: 'You are a helpful assistant' }, { text: 'Additional context here' }],
})
})

it('formats array system prompt with cache points', async () => {
const provider = new BedrockModel()
const messages: Message[] = [{ role: 'user', content: [{ type: 'textBlock', text: 'Hello' }] }]
const options: StreamOptions = {
systemPrompt: [
{ type: 'textBlock', text: 'You are a helpful assistant' },
{ type: 'textBlock', text: 'Large context document' },
{ type: 'cachePointBlock', cacheType: 'default' },
],
}

collectEvents(provider.stream(messages, options))

expect(mockConverseStreamCommand).toHaveBeenLastCalledWith({
modelId: 'global.anthropic.claude-sonnet-4-5-20250929-v1:0',
messages: [
{
role: 'user',
content: [{ text: 'Hello' }],
},
],
system: [
{ text: 'You are a helpful assistant' },
{ text: 'Large context document' },
{ cachePoint: { type: 'default' } },
],
})
})

it('warns when both array system prompt and cachePrompt config are provided', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const provider = new BedrockModel({ cachePrompt: 'default' })
const messages: Message[] = [{ role: 'user', content: [{ type: 'textBlock', text: 'Hello' }] }]
const options: StreamOptions = {
systemPrompt: [
{ type: 'textBlock', text: 'You are a helpful assistant' },
{ type: 'cachePointBlock', cacheType: 'default' },
],
}

collectEvents(provider.stream(messages, options))

// Verify warning was logged
expect(warnSpy).toHaveBeenCalledWith(
'cachePrompt config is ignored when systemPrompt is an array. Use explicit cache points in the array instead.'
)

// Verify array is used as-is (cachePrompt config ignored)
expect(mockConverseStreamCommand).toHaveBeenLastCalledWith({
modelId: 'global.anthropic.claude-sonnet-4-5-20250929-v1:0',
messages: [
{
role: 'user',
content: [{ text: 'Hello' }],
},
],
system: [{ text: 'You are a helpful assistant' }, { cachePoint: { type: 'default' } }],
})

warnSpy.mockRestore()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't needed, right? (don't have to change, just asking for understanding)

})

it('handles empty array system prompt', async () => {
const provider = new BedrockModel()
const messages: Message[] = [{ role: 'user', content: [{ type: 'textBlock', text: 'Hello' }] }]
const options: StreamOptions = {
systemPrompt: [],
}

collectEvents(provider.stream(messages, options))

// Empty array should not set system field
expect(mockConverseStreamCommand).toHaveBeenLastCalledWith({
modelId: 'global.anthropic.claude-sonnet-4-5-20250929-v1:0',
messages: [
{
role: 'user',
content: [{ text: 'Hello' }],
},
],
})
})
})
})
29 changes: 20 additions & 9 deletions src/models/bedrock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -349,18 +349,26 @@ export class BedrockModel implements Model<BedrockModelConfig, BedrockRuntimeCli
}

// Add system prompt with optional caching
if (options?.systemPrompt || this._config.cachePrompt) {
const system: BedrockContentBlock[] = []
if (options?.systemPrompt !== undefined) {
if (typeof options.systemPrompt === 'string') {
// String path: apply cachePrompt config if set
const system: BedrockContentBlock[] = [{ text: options.systemPrompt }]

if (options?.systemPrompt) {
system.push({ text: options.systemPrompt })
}
if (this._config.cachePrompt) {
system.push({ cachePoint: { type: this._config.cachePrompt as 'default' } })
}

if (this._config.cachePrompt) {
system.push({ cachePoint: { type: this._config.cachePrompt as 'default' } })
}
request.system = system
} else if (options.systemPrompt.length > 0) {
// Array path: use as-is, but warn if cachePrompt config is also set
if (this._config.cachePrompt) {
Comment thread
Unshure marked this conversation as resolved.
console.warn(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See #30 - console.log is okay for now, but we should come up with a more formal logging mechanism

Right now I'm playing around with https://github.com/pinojs/pino which seems to work okay

This comment is informational only

'cachePrompt config is ignored when systemPrompt is an array. Use explicit cache points in the array instead.'
)
}

request.system = system
request.system = options.systemPrompt.map((block) => this._formatContentBlock(block))
}
}

// Add tool configuration
Expand Down Expand Up @@ -494,6 +502,9 @@ export class BedrockModel implements Model<BedrockModelConfig, BedrockRuntimeCli
throw Error("reasoning content format incorrect. Either 'text' or 'redactedContent' must be set.")
}
}

case 'cachePointBlock':
return { cachePoint: { type: block.cacheType } }
}
}

Expand Down
5 changes: 3 additions & 2 deletions src/models/model.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Message } from '../types/messages'
import type { Message, SystemPrompt } from '../types/messages'
import type { ToolSpec, ToolChoice } from '../tools/types'
import type { ModelStreamEvent } from './streaming'

Expand All @@ -23,8 +23,9 @@ export interface BaseModelConfig {
export interface StreamOptions {
/**
* System prompt to guide the model's behavior.
* Can be a simple string or an array of content blocks for advanced caching.
*/
systemPrompt?: string
systemPrompt?: SystemPrompt

/**
* Array of tool specifications that the model can use.
Expand Down
47 changes: 45 additions & 2 deletions src/types/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,11 @@ export type Role = 'user' | 'assistant'

/**
* A block of content within a message.
* Content blocks can contain text, tool usage requests, tool results, or reasoning content.
* Content blocks can contain text, tool usage requests, tool results, reasoning content, or cache points.
*
* This is a discriminated union where the `type` field determines the content format.
*/
export type ContentBlock = TextBlock | ToolUseBlock | ToolResultBlock | ReasoningBlock
export type ContentBlock = TextBlock | ToolUseBlock | ToolResultBlock | ReasoningBlock | CachePointBlock

/**
* Text content block within a message.
Expand Down Expand Up @@ -122,6 +122,22 @@ export interface ReasoningBlock {
redactedContent?: Uint8Array
}

/**
* Cache point block for prompt caching.
* Marks a position in a message or system prompt where caching should occur.
*/
export interface CachePointBlock {
/**
* Discriminator for cache point.
*/
type: 'cachePointBlock'

/**
* The cache type. Currently only 'default' is supported.
*/
cacheType: 'default'
}

/**
* Reason why the model stopped generating content.
*
Expand All @@ -141,3 +157,30 @@ export type StopReason =
| 'toolUse'
| 'modelContextWindowExceeded'
| string

/**
* System prompt for guiding model behavior.
* Can be a simple string or an array of content blocks for advanced caching.
*
* @example
* ```typescript
* // Simple string
Comment thread
Unshure marked this conversation as resolved.
* const prompt: SystemPrompt = 'You are a helpful assistant'
Comment thread
Unshure marked this conversation as resolved.
*
* // Array with cache points for advanced caching
* const prompt: SystemPrompt = [
* { type: 'textBlock', text: 'You are a helpful assistant' },
* { type: 'textBlock', text: largeContextDocument },
* { type: 'cachePointBlock', cacheType: 'default' }
* ]
* ```
*/
export type SystemPrompt = string | SystemContentBlock[]

/**
* A block of content within a system prompt.
* Supports text content and cache points for prompt caching.
*
* This is a discriminated union where the `type` field determines the block format.
*/
export type SystemContentBlock = TextBlock | CachePointBlock
Loading