@namzu/bedrock 1.0.3 → 3.0.0

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 (35) hide show
  1. package/CHANGELOG.md +282 -0
  2. package/dist/__tests__/bedrock.test.js +44 -0
  3. package/dist/__tests__/bedrock.test.js.map +1 -1
  4. package/dist/__tests__/prompt-cache.test.d.ts +2 -0
  5. package/dist/__tests__/prompt-cache.test.d.ts.map +1 -0
  6. package/dist/__tests__/prompt-cache.test.js +68 -0
  7. package/dist/__tests__/prompt-cache.test.js.map +1 -0
  8. package/dist/__tests__/thinking-refused.test.d.ts +2 -0
  9. package/dist/__tests__/thinking-refused.test.d.ts.map +1 -0
  10. package/dist/__tests__/thinking-refused.test.js +35 -0
  11. package/dist/__tests__/thinking-refused.test.js.map +1 -0
  12. package/dist/__tests__/tool-choice-none.test.d.ts +2 -0
  13. package/dist/__tests__/tool-choice-none.test.d.ts.map +1 -0
  14. package/dist/__tests__/tool-choice-none.test.js +76 -0
  15. package/dist/__tests__/tool-choice-none.test.js.map +1 -0
  16. package/dist/__tests__/tool-content-blocks.test.d.ts +2 -0
  17. package/dist/__tests__/tool-content-blocks.test.d.ts.map +1 -0
  18. package/dist/__tests__/tool-content-blocks.test.js +92 -0
  19. package/dist/__tests__/tool-content-blocks.test.js.map +1 -0
  20. package/dist/__tests__/vision.test.d.ts +2 -0
  21. package/dist/__tests__/vision.test.d.ts.map +1 -0
  22. package/dist/__tests__/vision.test.js +77 -0
  23. package/dist/__tests__/vision.test.js.map +1 -0
  24. package/dist/client.d.ts +13 -1
  25. package/dist/client.d.ts.map +1 -1
  26. package/dist/client.js +20 -7
  27. package/dist/client.js.map +1 -1
  28. package/package.json +4 -4
  29. package/src/__tests__/bedrock.test.ts +47 -0
  30. package/src/__tests__/prompt-cache.test.ts +81 -0
  31. package/src/__tests__/thinking-refused.test.ts +44 -0
  32. package/src/__tests__/tool-choice-none.test.ts +87 -0
  33. package/src/__tests__/tool-content-blocks.test.ts +119 -0
  34. package/src/__tests__/vision.test.ts +98 -0
  35. package/src/client.ts +22 -8
@@ -0,0 +1,87 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { toBedrockToolConfig } from '../client.js'
4
+
5
+ /**
6
+ * `toolChoice: 'none'` means the model must not call a tool. This driver
7
+ * mapped it to the wire's "auto", which means the model MAY — the exact
8
+ * opposite of what the caller asked for, and silent.
9
+ *
10
+ * The runtime relies on it: an advisory consultation passes `'none'` so the
11
+ * advisor answers in prose. Under this driver the advisor could emit a tool
12
+ * call instead, into a turn with no executor waiting for one.
13
+ *
14
+ * The fix is not a better mapping. Every version of every wire format
15
+ * agrees a model cannot call a tool it was never given, so `'none'` sends
16
+ * no tools at all — a guarantee rather than a request.
17
+ */
18
+
19
+ const TOOLS = [
20
+ {
21
+ type: 'function' as const,
22
+ function: { name: 'read', description: 'read a file', parameters: { type: 'object' } },
23
+ },
24
+ ]
25
+
26
+ describe("a caller that said 'none' gets no tools on the wire", () => {
27
+ it('omits the tool config entirely', () => {
28
+ expect(
29
+ toBedrockToolConfig({ model: 'm', messages: [], tools: TOOLS, toolChoice: 'none' }),
30
+ ).toBe(undefined)
31
+ })
32
+
33
+ it('omits it even when the history contains tool blocks', () => {
34
+ const config = toBedrockToolConfig({
35
+ model: 'm',
36
+ messages: [
37
+ {
38
+ role: 'assistant',
39
+ content: null,
40
+ toolCalls: [{ id: 'c1', type: 'function', function: { name: 'read', arguments: '{}' } }],
41
+ },
42
+ { role: 'tool', content: 'ok', toolCallId: 'c1' },
43
+ ] as Parameters<typeof toBedrockToolConfig>[0]['messages'],
44
+ toolChoice: 'none',
45
+ })
46
+
47
+ expect(config).toBe(undefined)
48
+ })
49
+ })
50
+
51
+ describe('the other choices still reach the wire unchanged', () => {
52
+ it('maps auto', () => {
53
+ const config = toBedrockToolConfig({
54
+ model: 'm',
55
+ messages: [],
56
+ tools: TOOLS,
57
+ toolChoice: 'auto',
58
+ })
59
+ expect(config?.toolChoice).toEqual({ auto: {} })
60
+ expect(config?.tools).toHaveLength(1)
61
+ })
62
+
63
+ it('maps required', () => {
64
+ const config = toBedrockToolConfig({
65
+ model: 'm',
66
+ messages: [],
67
+ tools: TOOLS,
68
+ toolChoice: 'required',
69
+ })
70
+ expect(config?.toolChoice).toEqual({ any: {} })
71
+ })
72
+
73
+ it('maps a named tool', () => {
74
+ const config = toBedrockToolConfig({
75
+ model: 'm',
76
+ messages: [],
77
+ tools: TOOLS,
78
+ toolChoice: { type: 'function', function: { name: 'read' } },
79
+ })
80
+ expect(config?.toolChoice).toEqual({ tool: { name: 'read' } })
81
+ })
82
+
83
+ it('defaults to auto when the caller said nothing', () => {
84
+ const config = toBedrockToolConfig({ model: 'm', messages: [], tools: TOOLS })
85
+ expect(config?.toolChoice).toEqual({ auto: {} })
86
+ })
87
+ })
@@ -0,0 +1,119 @@
1
+ import type { ChatCompletionParams } from '@namzu/sdk'
2
+ import { describe, expect, it } from 'vitest'
3
+
4
+ import { toBedrockMessages } from '../client.js'
5
+
6
+ /**
7
+ * A tool result carrying content blocks was `JSON.stringify`d into the
8
+ * text field, so a screenshot reached the model as a wall of quoted base64:
9
+ * unreadable, and paid for by the character.
10
+ *
11
+ * This driver does not map image content — `supportsVision` is false and
12
+ * says so — which makes the honest form the SDK's named placeholder. It
13
+ * reports what was there and how big it was, so the model knows something
14
+ * was withheld rather than believing it saw a JSON object.
15
+ */
16
+
17
+ const PNG = 'iVBORw0KGgo='
18
+
19
+ function toolResultFor(content: unknown): Record<string, unknown> {
20
+ const messages = toBedrockMessages([
21
+ { role: 'user', content: 'take a shot' },
22
+ {
23
+ role: 'assistant',
24
+ content: null,
25
+ toolCalls: [
26
+ { id: 'call_1', type: 'function', function: { name: 'screenshot', arguments: '{}' } },
27
+ ],
28
+ },
29
+ { role: 'tool', toolCallId: 'call_1', content },
30
+ ] as unknown as ChatCompletionParams['messages'])
31
+
32
+ const block = messages
33
+ .flatMap((m) => m.content ?? [])
34
+ .find((b) => (b as { toolResult?: unknown }).toolResult)
35
+ return (block as unknown as { toolResult: Record<string, unknown> }).toolResult
36
+ }
37
+
38
+ function textOf(result: Record<string, unknown>): string {
39
+ const content = result.content as Array<{ text?: string }>
40
+ return content.map((c) => c.text ?? '').join('\n')
41
+ }
42
+
43
+ describe('a tool result the wire cannot carry is described, not dumped', () => {
44
+ it('names the media type and size instead of the payload', () => {
45
+ const text = textOf(toolResultFor([{ type: 'image', mediaType: 'image/png', data: PNG }]))
46
+
47
+ expect(text).toContain('image/png')
48
+ expect(text).toMatch(/\d+(\.\d+)? (B|KB|MB)/)
49
+ })
50
+
51
+ it('does not put the base64 payload in the text', () => {
52
+ const text = textOf(
53
+ toolResultFor([{ type: 'image', mediaType: 'image/png', data: 'A'.repeat(2048) }]),
54
+ )
55
+
56
+ // The bug in one assertion: the payload used to be JSON-quoted into
57
+ // the text field, so the model paid for 2048 unreadable characters.
58
+ expect(text).not.toContain('A'.repeat(64))
59
+ })
60
+
61
+ it('keeps the text blocks that sit alongside', () => {
62
+ const text = textOf(
63
+ toolResultFor([
64
+ { type: 'text', text: 'captured the window' },
65
+ { type: 'image', mediaType: 'image/png', data: PNG },
66
+ ]),
67
+ )
68
+
69
+ expect(text).toContain('captured the window')
70
+ expect(text).toContain('image/png')
71
+ })
72
+
73
+ it('names a document by its filename', () => {
74
+ const text = textOf(
75
+ toolResultFor([
76
+ { type: 'document', mediaType: 'application/pdf', data: 'JVBER', name: 'lease.pdf' },
77
+ ]),
78
+ )
79
+
80
+ expect(text).toContain('lease.pdf')
81
+ expect(text).toContain('application/pdf')
82
+ })
83
+ })
84
+
85
+ describe('the plain cases are unchanged', () => {
86
+ it('passes a string result straight through', () => {
87
+ expect(textOf(toolResultFor('just text'))).toBe('just text')
88
+ })
89
+
90
+ it('passes text-only blocks through as their text', () => {
91
+ expect(textOf(toolResultFor([{ type: 'text', text: 'only words' }]))).toBe('only words')
92
+ })
93
+
94
+ it('answers the tool call it was given', () => {
95
+ expect(toolResultFor('x').toolUseId).toBe('call_1')
96
+ })
97
+
98
+ it('groups results answering one turn into a single message', () => {
99
+ const messages = toBedrockMessages([
100
+ { role: 'user', content: 'read both' },
101
+ {
102
+ role: 'assistant',
103
+ content: null,
104
+ toolCalls: [
105
+ { id: 'call_1', type: 'function', function: { name: 'read', arguments: '{}' } },
106
+ { id: 'call_2', type: 'function', function: { name: 'read', arguments: '{}' } },
107
+ ],
108
+ },
109
+ { role: 'tool', toolCallId: 'call_1', content: 'one' },
110
+ { role: 'tool', toolCallId: 'call_2', content: 'two' },
111
+ ] as unknown as ChatCompletionParams['messages'])
112
+
113
+ const resultTurns = messages.filter((m) =>
114
+ (m.content ?? []).every((b) => (b as { toolResult?: unknown }).toolResult),
115
+ )
116
+ expect(resultTurns).toHaveLength(1)
117
+ expect(resultTurns[0]?.content).toHaveLength(2)
118
+ })
119
+ })
@@ -0,0 +1,98 @@
1
+ import type { ChatCompletionParams } from '@namzu/sdk'
2
+ import { describe, expect, it } from 'vitest'
3
+
4
+ import { BEDROCK_CAPABILITIES, toBedrockMessages } from '../client.js'
5
+
6
+ /**
7
+ * This driver does not map image attachments, and says so.
8
+ *
9
+ * That is a legitimate state — a driver may ship without a wire shape —
10
+ * but only while the claim and the code agree. The failure this pins is
11
+ * drift in either direction: flipping the flag without writing the mapping
12
+ * makes the runtime stop warning about images that still vanish, and
13
+ * writing the mapping without flipping the flag makes it keep warning
14
+ * about images that now arrive.
15
+ *
16
+ * The runtime reads the flag and warns (or fails under `strictCapabilities`)
17
+ * before the request is built, so the flag is the contract.
18
+ */
19
+
20
+ const PNG = 'iVBORw0KGgo='
21
+
22
+ function contentFor(attachments: unknown[]): unknown {
23
+ const messages = toBedrockMessages([
24
+ { role: 'user', content: 'what is this', attachments },
25
+ ] as unknown as ChatCompletionParams['messages'])
26
+ return messages[0]?.content
27
+ }
28
+
29
+ describe('the capability claim and the mapper agree', () => {
30
+ it('declares that images are not mapped', () => {
31
+ expect(BEDROCK_CAPABILITIES.supportsVision).toBe(false)
32
+ })
33
+
34
+ it('declares that documents are not mapped', () => {
35
+ expect(BEDROCK_CAPABILITIES.supportsDocuments).toBe(false)
36
+ })
37
+
38
+ it('sends no image block, matching the claim', () => {
39
+ const content = contentFor([{ type: 'image', data: PNG, mediaType: 'image/png' }])
40
+ const blocks = content as Record<string, unknown>[]
41
+
42
+ expect(blocks.some((b) => 'image' in b)).toBe(false)
43
+ })
44
+
45
+ it('does not smuggle the payload into the text either', () => {
46
+ const content = contentFor([{ type: 'image', data: 'A'.repeat(2048), mediaType: 'image/png' }])
47
+
48
+ // Dropping is the declared behaviour. Stringifying it into the
49
+ // prompt would be worse than dropping: unreadable, and billed.
50
+ expect(JSON.stringify(content)).not.toContain('A'.repeat(64))
51
+ })
52
+
53
+ it('still sends the message text', () => {
54
+ const content = contentFor([{ type: 'image', data: PNG, mediaType: 'image/png' }])
55
+ const blocks = content as Array<{ text?: string }>
56
+
57
+ expect(blocks.some((b) => b.text === 'what is this')).toBe(true)
58
+ })
59
+
60
+ it('leaves a plain message untouched', () => {
61
+ const messages = toBedrockMessages([
62
+ { role: 'user', content: 'no attachments' },
63
+ ] as ChatCompletionParams['messages'])
64
+
65
+ expect(messages[0]?.content).toEqual([{ text: 'no attachments' }])
66
+ })
67
+ })
68
+
69
+ describe('what the driver does map is unaffected', () => {
70
+ it('maps tool calls, which is what this driver is for', () => {
71
+ const messages = toBedrockMessages([
72
+ { role: 'user', content: 'read it' },
73
+ {
74
+ role: 'assistant',
75
+ content: null,
76
+ toolCalls: [
77
+ { id: 'call_1', type: 'function', function: { name: 'read', arguments: '{"p":1}' } },
78
+ ],
79
+ },
80
+ ] as unknown as ChatCompletionParams['messages'])
81
+
82
+ const toolUse = (messages[1]?.content ?? []).find(
83
+ (b) => (b as { toolUse?: unknown }).toolUse,
84
+ ) as { toolUse: { name: string; input: unknown } }
85
+
86
+ expect(toolUse.toolUse.name).toBe('read')
87
+ expect(toolUse.toolUse.input).toEqual({ p: 1 })
88
+ })
89
+
90
+ it('drops system messages from the turn list', () => {
91
+ const messages = toBedrockMessages([
92
+ { role: 'system', content: 'be terse' },
93
+ { role: 'user', content: 'hi' },
94
+ ] as ChatCompletionParams['messages'])
95
+
96
+ expect(messages.map((m) => m.role)).toEqual(['user'])
97
+ })
98
+ })
package/src/client.ts CHANGED
@@ -24,6 +24,8 @@ import type {
24
24
  TokenUsage,
25
25
  ToolChoice,
26
26
  } from '@namzu/sdk'
27
+ import type { ToolResultContent } from '@namzu/sdk'
28
+ import { assertThinkingUnsupported, toolResultToText } from '@namzu/sdk'
27
29
  import {
28
30
  ProviderRequestError,
29
31
  isCallerAbortError,
@@ -42,7 +44,7 @@ function toBedrockRole(role: string): ConversationRole {
42
44
  return role === 'assistant' ? 'assistant' : 'user'
43
45
  }
44
46
 
45
- function toBedrockMessages(messages: ChatCompletionParams['messages']): BedrockMessage[] {
47
+ export function toBedrockMessages(messages: ChatCompletionParams['messages']): BedrockMessage[] {
46
48
  const out: BedrockMessage[] = []
47
49
 
48
50
  let pendingToolResults: ContentBlock[] = []
@@ -58,10 +60,14 @@ function toBedrockMessages(messages: ChatCompletionParams['messages']): BedrockM
58
60
  if (msg.role === 'system') continue
59
61
 
60
62
  if (msg.role === 'tool') {
61
- const toolMsg = msg as { toolCallId?: string; content?: string }
63
+ const toolMsg = msg as { toolCallId?: string; content?: ToolResultContent }
64
+ // `JSON.stringify` used to run here, so a tool result carrying an
65
+ // image sent the model a wall of quoted base64: unreadable, and
66
+ // paid for by the character. This driver does not map image
67
+ // content, so the honest form is the SDK's named placeholder —
68
+ // which says what was there and how big it was.
62
69
  const resultBlock: ToolResultContentBlock = {
63
- text:
64
- typeof toolMsg.content === 'string' ? toolMsg.content : JSON.stringify(toolMsg.content),
70
+ text: toolResultToText(toolMsg.content ?? ''),
65
71
  }
66
72
  pendingToolResults.push({
67
73
  toolResult: {
@@ -131,7 +137,12 @@ function extractToolNamesFromHistory(messages: ChatCompletionParams['messages'])
131
137
  return Array.from(names)
132
138
  }
133
139
 
134
- function toBedrockToolConfig(params: ChatCompletionParams): ToolConfiguration | undefined {
140
+ export function toBedrockToolConfig(params: ChatCompletionParams): ToolConfiguration | undefined {
141
+ // 'none' means the model must not call a tool. This used to map to the
142
+ // wire's 'auto', which means it MAY — the opposite, and silent. No wire
143
+ // format lets a model call a tool it was never given, so send none.
144
+ if (params.toolChoice === 'none') return undefined
145
+
135
146
  if (params.tools && params.tools.length > 0) {
136
147
  const tools: Tool[] = params.tools.map(
137
148
  (t) =>
@@ -170,9 +181,9 @@ function toBedrockToolConfig(params: ChatCompletionParams): ToolConfiguration |
170
181
  return undefined
171
182
  }
172
183
 
184
+ /** 'none' never reaches here — it is answered by omitting the tools. */
173
185
  function formatToolChoice(tc?: ToolChoice) {
174
186
  if (!tc || tc === 'auto') return { auto: {} }
175
- if (tc === 'none') return { auto: {} }
176
187
  if (tc === 'required') return { any: {} }
177
188
  if (typeof tc === 'object' && tc.type === 'function') {
178
189
  return { tool: { name: tc.function.name } }
@@ -188,7 +199,7 @@ interface RawBedrockUsage {
188
199
  cacheWriteInputTokenCount?: number
189
200
  }
190
201
 
191
- function parseUsage(raw?: RawBedrockUsage): TokenUsage {
202
+ export function parseBedrockUsage(raw?: RawBedrockUsage): TokenUsage {
192
203
  if (!raw) {
193
204
  return {
194
205
  promptTokens: 0,
@@ -238,6 +249,8 @@ export const BEDROCK_CAPABILITIES: ProviderCapabilities = {
238
249
  supportsStreaming: true,
239
250
  supportsFunctionCalling: true,
240
251
  supportsVision: false,
252
+ // Images only. A document degrades to a named placeholder.
253
+ supportsDocuments: false,
241
254
  }
242
255
 
243
256
  export class BedrockProvider implements LLMProvider {
@@ -269,6 +282,7 @@ export class BedrockProvider implements LLMProvider {
269
282
  }
270
283
 
271
284
  async *chatStream(params: ChatCompletionParams): AsyncIterable<StreamChunk> {
285
+ assertThinkingUnsupported('BedrockProvider', params)
272
286
  const system = extractSystemBlocks(params.messages)
273
287
  const messages = toBedrockMessages(params.messages)
274
288
  const toolConfig = toBedrockToolConfig(params)
@@ -407,7 +421,7 @@ export class BedrockProvider implements LLMProvider {
407
421
  }
408
422
 
409
423
  if ('metadata' in event && event.metadata?.usage) {
410
- const usage = parseUsage(event.metadata.usage as RawBedrockUsage)
424
+ const usage = parseBedrockUsage(event.metadata.usage as RawBedrockUsage)
411
425
  yield {
412
426
  id: requestId,
413
427
  delta: {},