@namzu/bedrock 1.0.2 → 2.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 +270 -0
  2. package/dist/__tests__/bedrock.test.js +44 -0
  3. package/dist/__tests__/bedrock.test.js.map +1 -1
  4. package/dist/__tests__/error-taxonomy.test.d.ts +17 -0
  5. package/dist/__tests__/error-taxonomy.test.d.ts.map +1 -0
  6. package/dist/__tests__/error-taxonomy.test.js +192 -0
  7. package/dist/__tests__/error-taxonomy.test.js.map +1 -0
  8. package/dist/__tests__/prompt-cache.test.d.ts +2 -0
  9. package/dist/__tests__/prompt-cache.test.d.ts.map +1 -0
  10. package/dist/__tests__/prompt-cache.test.js +68 -0
  11. package/dist/__tests__/prompt-cache.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 +130 -76
  27. package/dist/client.js.map +1 -1
  28. package/package.json +5 -5
  29. package/src/__tests__/bedrock.test.ts +47 -0
  30. package/src/__tests__/error-taxonomy.test.ts +236 -0
  31. package/src/__tests__/prompt-cache.test.ts +81 -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 +134 -77
@@ -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
@@ -7,6 +7,7 @@ import type {
7
7
  Message as BedrockMessage,
8
8
  ContentBlock,
9
9
  ConversationRole,
10
+ ConverseStreamCommandOutput,
10
11
  ConverseStreamOutput,
11
12
  SystemContentBlock,
12
13
  Tool,
@@ -23,6 +24,14 @@ import type {
23
24
  TokenUsage,
24
25
  ToolChoice,
25
26
  } from '@namzu/sdk'
27
+ import type { ToolResultContent } from '@namzu/sdk'
28
+ import { toolResultToText } from '@namzu/sdk'
29
+ import {
30
+ ProviderRequestError,
31
+ isCallerAbortError,
32
+ isProviderRequestError,
33
+ providerVendorError,
34
+ } from '@namzu/sdk'
26
35
  import type { BedrockConfig } from './types.js'
27
36
 
28
37
  function extractSystemBlocks(messages: ChatCompletionParams['messages']): SystemContentBlock[] {
@@ -35,7 +44,7 @@ function toBedrockRole(role: string): ConversationRole {
35
44
  return role === 'assistant' ? 'assistant' : 'user'
36
45
  }
37
46
 
38
- function toBedrockMessages(messages: ChatCompletionParams['messages']): BedrockMessage[] {
47
+ export function toBedrockMessages(messages: ChatCompletionParams['messages']): BedrockMessage[] {
39
48
  const out: BedrockMessage[] = []
40
49
 
41
50
  let pendingToolResults: ContentBlock[] = []
@@ -51,10 +60,14 @@ function toBedrockMessages(messages: ChatCompletionParams['messages']): BedrockM
51
60
  if (msg.role === 'system') continue
52
61
 
53
62
  if (msg.role === 'tool') {
54
- 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.
55
69
  const resultBlock: ToolResultContentBlock = {
56
- text:
57
- typeof toolMsg.content === 'string' ? toolMsg.content : JSON.stringify(toolMsg.content),
70
+ text: toolResultToText(toolMsg.content ?? ''),
58
71
  }
59
72
  pendingToolResults.push({
60
73
  toolResult: {
@@ -124,7 +137,12 @@ function extractToolNamesFromHistory(messages: ChatCompletionParams['messages'])
124
137
  return Array.from(names)
125
138
  }
126
139
 
127
- 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
+
128
146
  if (params.tools && params.tools.length > 0) {
129
147
  const tools: Tool[] = params.tools.map(
130
148
  (t) =>
@@ -163,9 +181,9 @@ function toBedrockToolConfig(params: ChatCompletionParams): ToolConfiguration |
163
181
  return undefined
164
182
  }
165
183
 
184
+ /** 'none' never reaches here — it is answered by omitting the tools. */
166
185
  function formatToolChoice(tc?: ToolChoice) {
167
186
  if (!tc || tc === 'auto') return { auto: {} }
168
- if (tc === 'none') return { auto: {} }
169
187
  if (tc === 'required') return { any: {} }
170
188
  if (typeof tc === 'object' && tc.type === 'function') {
171
189
  return { tool: { name: tc.function.name } }
@@ -181,7 +199,7 @@ interface RawBedrockUsage {
181
199
  cacheWriteInputTokenCount?: number
182
200
  }
183
201
 
184
- function parseUsage(raw?: RawBedrockUsage): TokenUsage {
202
+ export function parseBedrockUsage(raw?: RawBedrockUsage): TokenUsage {
185
203
  if (!raw) {
186
204
  return {
187
205
  promptTokens: 0,
@@ -231,6 +249,8 @@ export const BEDROCK_CAPABILITIES: ProviderCapabilities = {
231
249
  supportsStreaming: true,
232
250
  supportsFunctionCalling: true,
233
251
  supportsVision: false,
252
+ // Images only. A document degrades to a named placeholder.
253
+ supportsDocuments: false,
234
254
  }
235
255
 
236
256
  export class BedrockProvider implements LLMProvider {
@@ -280,14 +300,29 @@ export class BedrockProvider implements LLMProvider {
280
300
  inferenceConfig,
281
301
  })
282
302
 
283
- const response = await this.client.send(command, {
284
- requestTimeout: this.config.timeout ?? 120_000,
285
- // Per-request abort: a Stop tears the in-flight Converse stream down.
286
- abortSignal: params.signal,
287
- })
303
+ // AWS models failures as distinct exception CLASSES rather than status
304
+ // codes (ThrottlingException, ValidationException, AccessDeniedException),
305
+ // and lets them escape verbatim. Classify from the class name, then drop
306
+ // the AWS error — no re-throw, no `cause`.
307
+ let response: ConverseStreamCommandOutput
308
+ try {
309
+ response = await this.client.send(command, {
310
+ requestTimeout: this.config.timeout ?? 120_000,
311
+ // Per-request abort: a Stop tears the in-flight Converse stream down.
312
+ abortSignal: params.signal,
313
+ })
314
+ } catch (err) {
315
+ if (isCallerAbortError(err, params.signal)) throw params.signal?.reason ?? err
316
+ if (isProviderRequestError(err)) throw err
317
+ throw providerVendorError({ providerId: 'bedrock', error: err })
318
+ }
288
319
 
289
320
  if (!response.stream) {
290
- throw new Error('Bedrock returned no stream body')
321
+ throw new ProviderRequestError({
322
+ kind: 'server',
323
+ providerId: 'bedrock',
324
+ detail: 'the response contained no stream body',
325
+ })
291
326
  }
292
327
 
293
328
  const requestId = response.$metadata.requestId ?? `bedrock-${Date.now()}`
@@ -295,94 +330,116 @@ export class BedrockProvider implements LLMProvider {
295
330
  const activeToolCalls = new Map<number, { id: string; name: string; args: string }>()
296
331
  let toolCallIndex = 0
297
332
 
298
- for await (const event of response.stream as AsyncIterable<ConverseStreamOutput>) {
299
- // Stop pulling promptly on abort; `for await` calls the stream's
300
- // `.return()` on this throw, releasing the connection.
301
- params.signal?.throwIfAborted()
302
- try {
303
- if ('contentBlockDelta' in event && event.contentBlockDelta?.delta) {
304
- const delta = event.contentBlockDelta.delta
305
- if ('text' in delta && delta.text) {
306
- yield {
307
- id: requestId,
308
- delta: { content: delta.text },
333
+ try {
334
+ for await (const event of response.stream as AsyncIterable<ConverseStreamOutput>) {
335
+ // Stop pulling promptly on abort; `for await` calls the stream's
336
+ // `.return()` on this throw, releasing the connection.
337
+ params.signal?.throwIfAborted()
338
+
339
+ // Bedrock reports several post-handshake failures as UNION
340
+ // EVENTS, not thrown exceptions. Ignoring these members makes a
341
+ // throttled or failed stream look like a clean EOF.
342
+ const streamFailure =
343
+ ('internalServerException' in event ? event.internalServerException : undefined) ??
344
+ ('modelStreamErrorException' in event ? event.modelStreamErrorException : undefined) ??
345
+ ('validationException' in event ? event.validationException : undefined) ??
346
+ ('throttlingException' in event ? event.throttlingException : undefined) ??
347
+ ('serviceUnavailableException' in event ? event.serviceUnavailableException : undefined)
348
+ if (streamFailure) {
349
+ throw providerVendorError({
350
+ providerId: 'bedrock',
351
+ error: streamFailure,
352
+ })
353
+ }
354
+
355
+ try {
356
+ if ('contentBlockDelta' in event && event.contentBlockDelta?.delta) {
357
+ const delta = event.contentBlockDelta.delta
358
+ if ('text' in delta && delta.text) {
359
+ yield {
360
+ id: requestId,
361
+ delta: { content: delta.text },
362
+ }
363
+ }
364
+
365
+ if ('toolUse' in delta && delta.toolUse) {
366
+ const idx = event.contentBlockDelta.contentBlockIndex ?? toolCallIndex
367
+ const active = activeToolCalls.get(idx)
368
+ if (active) {
369
+ active.args += delta.toolUse.input ?? ''
370
+ yield {
371
+ id: requestId,
372
+ delta: {
373
+ toolCalls: [
374
+ {
375
+ index: idx,
376
+ function: { arguments: delta.toolUse.input ?? '' },
377
+ },
378
+ ],
379
+ },
380
+ }
381
+ }
309
382
  }
310
383
  }
311
384
 
312
- if ('toolUse' in delta && delta.toolUse) {
313
- const idx = event.contentBlockDelta.contentBlockIndex ?? toolCallIndex
314
- const active = activeToolCalls.get(idx)
315
- if (active) {
316
- active.args += delta.toolUse.input ?? ''
385
+ if ('contentBlockStart' in event && event.contentBlockStart?.start) {
386
+ const start = event.contentBlockStart.start
387
+ if ('toolUse' in start && start.toolUse) {
388
+ const idx = event.contentBlockStart.contentBlockIndex ?? toolCallIndex
389
+ activeToolCalls.set(idx, {
390
+ id: start.toolUse.toolUseId ?? `tool-${Date.now()}`,
391
+ name: start.toolUse.name ?? '',
392
+ args: '',
393
+ })
317
394
  yield {
318
395
  id: requestId,
319
396
  delta: {
320
397
  toolCalls: [
321
398
  {
322
399
  index: idx,
323
- function: { arguments: delta.toolUse.input ?? '' },
400
+ id: start.toolUse.toolUseId,
401
+ type: 'function',
402
+ function: { name: start.toolUse.name ?? '' },
324
403
  },
325
404
  ],
326
405
  },
327
406
  }
407
+ toolCallIndex = idx + 1
328
408
  }
329
409
  }
330
- }
331
410
 
332
- if ('contentBlockStart' in event && event.contentBlockStart?.start) {
333
- const start = event.contentBlockStart.start
334
- if ('toolUse' in start && start.toolUse) {
335
- const idx = event.contentBlockStart.contentBlockIndex ?? toolCallIndex
336
- activeToolCalls.set(idx, {
337
- id: start.toolUse.toolUseId ?? `tool-${Date.now()}`,
338
- name: start.toolUse.name ?? '',
339
- args: '',
340
- })
411
+ if ('contentBlockStop' in event) {
412
+ }
413
+
414
+ if ('messageStop' in event && event.messageStop) {
341
415
  yield {
342
416
  id: requestId,
343
- delta: {
344
- toolCalls: [
345
- {
346
- index: idx,
347
- id: start.toolUse.toolUseId,
348
- type: 'function',
349
- function: { name: start.toolUse.name ?? '' },
350
- },
351
- ],
352
- },
417
+ delta: {},
418
+ finishReason: mapStopReason(event.messageStop.stopReason),
353
419
  }
354
- toolCallIndex = idx + 1
355
420
  }
356
- }
357
421
 
358
- if ('contentBlockStop' in event) {
359
- }
360
-
361
- if ('messageStop' in event && event.messageStop) {
362
- yield {
363
- id: requestId,
364
- delta: {},
365
- finishReason: mapStopReason(event.messageStop.stopReason),
366
- }
367
- }
368
-
369
- if ('metadata' in event && event.metadata?.usage) {
370
- const usage = parseUsage(event.metadata.usage as RawBedrockUsage)
371
- yield {
372
- id: requestId,
373
- delta: {},
374
- usage,
422
+ if ('metadata' in event && event.metadata?.usage) {
423
+ const usage = parseBedrockUsage(event.metadata.usage as RawBedrockUsage)
424
+ yield {
425
+ id: requestId,
426
+ delta: {},
427
+ usage,
428
+ }
375
429
  }
376
- }
377
- } catch (parseErr) {
378
- yield {
379
- id: requestId,
380
- delta: { content: undefined },
381
- finishReason: undefined,
382
- usage: undefined,
383
- error: `Stream parse error: ${parseErr instanceof Error ? parseErr.message : String(parseErr)}`,
430
+ } catch (parseErr) {
431
+ if (isProviderRequestError(parseErr)) throw parseErr
432
+ throw new ProviderRequestError({
433
+ kind: 'server',
434
+ providerId: 'bedrock',
435
+ detail: 'the provider stream returned malformed data',
436
+ })
384
437
  }
385
438
  }
439
+ } catch (err) {
440
+ if (isCallerAbortError(err, params.signal)) throw params.signal?.reason ?? err
441
+ if (isProviderRequestError(err)) throw err
442
+ throw providerVendorError({ providerId: 'bedrock', error: err })
386
443
  }
387
444
  }
388
445