@namzu/bedrock 4.0.1 → 5.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.
package/dist/health.js ADDED
@@ -0,0 +1,131 @@
1
+ /**
2
+ * What a health probe against this wire concluded, and why.
3
+ *
4
+ * `healthCheck` used to answer with one bit produced by `catch { return false }`.
5
+ * The three failures a caller actually needs apart — the credential is wrong,
6
+ * the service is down, that model is not invokable here — all arrived as
7
+ * `false`, and so did success, because the id it probed was one the driver's
8
+ * own `assertModelReachable` classifies as unreachable. A check that cannot
9
+ * pass is the same defect class as a check that cannot fail: neither carries
10
+ * information, and this one carried none in a shape that looked like an outage.
11
+ *
12
+ * So the reason is a value rather than a sentence. `status` is the
13
+ * `DoctorCheckResult` field `runDoctor()` reads; `reason` is what a caller
14
+ * switches on.
15
+ */
16
+ import { providerVendorError } from '@namzu/sdk';
17
+ /**
18
+ * AWS models its failures as distinct exception CLASSES rather than as status
19
+ * codes, and the class is the only thing that separates the cases an operator
20
+ * acts on differently.
21
+ *
22
+ * The SDK's `providerVendorError` deliberately flattens these into six kinds
23
+ * for the request path, where the question is "retry, compact, or give up".
24
+ * That is the right shape there and the wrong one here: it merges
25
+ * `ResourceNotFoundException` ("no such model in this region") with
26
+ * `ValidationException` ("the request was invalid") into `bad_request`, and
27
+ * those are the two answers a health check exists to tell apart. So the class
28
+ * is read here, where the raw error is still in hand.
29
+ *
30
+ * Matched on `name` rather than with `instanceof`: the exception classes are
31
+ * re-exported by every minor of the AWS SDK, and a caller running two copies of
32
+ * `@aws-sdk/client-bedrock-runtime` in one tree would fail every `instanceof`
33
+ * against errors that are otherwise correct.
34
+ */
35
+ const REASON_BY_EXCEPTION = [
36
+ // Client-side, before a packet leaves: the credential chain found nothing.
37
+ // This is a definite answer about the machine, not a failure to reach AWS,
38
+ // and filing it as unreachable would tell someone with no credentials at
39
+ // all to go and check their network.
40
+ [/CredentialsProviderError|CredentialsError/i, 'no-credentials'],
41
+ [/AccessDenied|UnrecognizedClient|InvalidSignature|Unauthorized|Forbidden/i, 'credentials'],
42
+ [/ResourceNotFound/i, 'unknown-model'],
43
+ [/ThrottlingException|TooManyRequests|ServiceQuotaExceeded/i, 'throttled'],
44
+ [
45
+ /ServiceUnavailable|InternalServer|ModelNotReady|ModelTimeout|ModelStreamError|ModelError/i,
46
+ 'service',
47
+ ],
48
+ [/Validation/i, 'refused'],
49
+ ];
50
+ const STATUS_BY_REASON = {
51
+ ok: 'pass',
52
+ 'no-model': 'skipped',
53
+ 'unreachable-model': 'fail',
54
+ 'no-credentials': 'fail',
55
+ credentials: 'fail',
56
+ 'unknown-model': 'fail',
57
+ refused: 'fail',
58
+ // Reached, authenticated, throttled. The service is up, so this is not a
59
+ // failure of the integration — but the probe did not complete, so it is
60
+ // not a pass either, and reporting it as one would let a caller start
61
+ // sending traffic on the strength of a request that never ran.
62
+ throttled: 'warn',
63
+ service: 'fail',
64
+ // The check did not answer. Distinct from `fail` because nothing is known:
65
+ // telling an operator on broken wifi that Bedrock is down sends them to
66
+ // the wrong place.
67
+ 'unreachable-service': 'inconclusive',
68
+ };
69
+ const REMEDIATION_BY_REASON = {
70
+ 'no-credentials': 'Pass accessKeyId/secretAccessKey to the driver, or make the AWS default credential chain resolve here (env, shared config, SSO, or an instance role).',
71
+ credentials: 'Check the credential is current and that its IAM policy allows bedrock:InvokeModelWithResponseStream on this model in this region.',
72
+ 'unknown-model': 'This region serves no model by that id. Enable model access in the target region, or use the inference-profile form (us./eu./apac./jp./global.) for a model served cross-region.',
73
+ refused: 'The service rejected the request itself. A model served only through an inference profile refuses the bare id — try the profile-prefixed form.',
74
+ throttled: 'Retry. The service is reachable and the credential authenticated.',
75
+ service: 'Nothing to do here; the service could not serve the request. Retry later.',
76
+ 'unreachable-service': 'Nothing was learned about the service. Check network reachability of the Bedrock endpoint for this region before changing any credential.',
77
+ };
78
+ /** The reason an AWS failure implies; "nothing was learned" when unrecognised. */
79
+ export function reasonForError(err) {
80
+ // Read off the object rather than gated on an instanceof check. Bedrock
81
+ // reports several failures as members of the stream's output union, and a
82
+ // deserialiser that hands one back as a plain shape would otherwise fall all
83
+ // the way through to "nothing was learned" while naming its own class.
84
+ const raw = err?.name;
85
+ const name = typeof raw === 'string' ? raw : '';
86
+ for (const [pattern, reason] of REASON_BY_EXCEPTION) {
87
+ if (pattern.test(name))
88
+ return reason;
89
+ }
90
+ // A region that was never configured also never leaves the machine, and the
91
+ // AWS SDK reports it as a plain `Error`, so there is no class to match.
92
+ const message = err instanceof Error ? err.message : String(err);
93
+ if (/region is missing|missing region/i.test(message))
94
+ return 'no-credentials';
95
+ return 'unreachable-service';
96
+ }
97
+ /**
98
+ * Build the report for a failure.
99
+ *
100
+ * The vendor error's message is taken from `providerVendorError` rather than
101
+ * from `err.message`, and that is not stylistic. AWS builds an exception
102
+ * message from the response body, so a credential the service echoed back is
103
+ * already inside `err.message` before this code sees it; `providerVendorError`
104
+ * is the one place in this estate that strips it. The class name is read off
105
+ * the raw error first — a name is not a body — and then the raw error is
106
+ * dropped.
107
+ */
108
+ export function reportForError(err, model, durationMs) {
109
+ const reason = reasonForError(err);
110
+ const safe = providerVendorError({ providerId: 'bedrock', error: err });
111
+ return {
112
+ status: STATUS_BY_REASON[reason],
113
+ reason,
114
+ model,
115
+ message: safe.message,
116
+ ...(REMEDIATION_BY_REASON[reason] ? { remediation: REMEDIATION_BY_REASON[reason] } : {}),
117
+ durationMs,
118
+ };
119
+ }
120
+ /** The report for a reason that needs no vendor error to describe it. */
121
+ export function report(reason, message, extra = {}) {
122
+ return {
123
+ status: STATUS_BY_REASON[reason],
124
+ reason,
125
+ message,
126
+ ...(extra.model !== undefined ? { model: extra.model } : {}),
127
+ ...(REMEDIATION_BY_REASON[reason] ? { remediation: REMEDIATION_BY_REASON[reason] } : {}),
128
+ ...(extra.durationMs !== undefined ? { durationMs: extra.durationMs } : {}),
129
+ };
130
+ }
131
+ //# sourceMappingURL=health.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"health.js","sourceRoot":"","sources":["../src/health.ts"],"names":[],"mappings":"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"}
package/dist/index.d.ts CHANGED
@@ -15,5 +15,6 @@ declare module '@namzu/sdk' {
15
15
  */
16
16
  export declare function registerBedrock(options?: RegisterOptions): void;
17
17
  export { BEDROCK_CAPABILITIES, BedrockProvider } from './client.js';
18
+ export type { BedrockHealthReason, BedrockHealthReport } from './health.js';
18
19
  export type { BedrockConfig, BedrockProviderConfig } from './types.js';
19
20
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAoB,KAAK,eAAe,EAAE,MAAM,YAAY,CAAA;AAEnE,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,YAAY,CAAA;AAIvD,OAAO,QAAQ,YAAY,CAAC;IAC3B,UAAU,sBAAsB;QAC/B,OAAO,EAAE,qBAAqB,CAAA;KAC9B;CACD;AAED;;;;;;;GAOG;AACH,wBAAgB,eAAe,CAAC,OAAO,CAAC,EAAE,eAAe,GAAG,IAAI,CAE/D;AAED,OAAO,EAAE,oBAAoB,EAAE,eAAe,EAAE,MAAM,aAAa,CAAA;AACnE,YAAY,EAAE,aAAa,EAAE,qBAAqB,EAAE,MAAM,YAAY,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAoB,KAAK,eAAe,EAAE,MAAM,YAAY,CAAA;AAEnE,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,YAAY,CAAA;AAIvD,OAAO,QAAQ,YAAY,CAAC;IAC3B,UAAU,sBAAsB;QAC/B,OAAO,EAAE,qBAAqB,CAAA;KAC9B;CACD;AAED;;;;;;;GAOG;AACH,wBAAgB,eAAe,CAAC,OAAO,CAAC,EAAE,eAAe,GAAG,IAAI,CAE/D;AAED,OAAO,EAAE,oBAAoB,EAAE,eAAe,EAAE,MAAM,aAAa,CAAA;AACnE,YAAY,EAAE,mBAAmB,EAAE,mBAAmB,EAAE,MAAM,aAAa,CAAA;AAC3E,YAAY,EAAE,aAAa,EAAE,qBAAqB,EAAE,MAAM,YAAY,CAAA"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@namzu/bedrock",
3
- "version": "4.0.1",
3
+ "version": "5.0.0",
4
4
  "description": "AWS Bedrock LLM provider for @namzu/sdk. Provides Converse API (chat + streaming) with full tool-use support.",
5
5
  "license": "FSL-1.1-MIT",
6
6
  "type": "module",
@@ -54,7 +54,7 @@
54
54
  "@types/node": "^22.19.17",
55
55
  "typescript": "^5.5.0",
56
56
  "vitest": "^3.2.6",
57
- "@namzu/sdk": "^15.0.0"
57
+ "@namzu/sdk": "^26.0.0"
58
58
  },
59
59
  "scripts": {
60
60
  "build": "tsc --build",
package/src/client.ts CHANGED
@@ -1,10 +1,7 @@
1
- import {
2
- BedrockRuntimeClient,
3
- ConverseCommand,
4
- ConverseStreamCommand,
5
- } from '@aws-sdk/client-bedrock-runtime'
1
+ import { BedrockRuntimeClient, ConverseStreamCommand } from '@aws-sdk/client-bedrock-runtime'
6
2
  import type {
7
3
  Message as BedrockMessage,
4
+ CachePointBlock,
8
5
  ContentBlock,
9
6
  ConversationRole,
10
7
  ConverseStreamCommandOutput,
@@ -32,13 +29,75 @@ import {
32
29
  isProviderRequestError,
33
30
  providerVendorError,
34
31
  } from '@namzu/sdk'
32
+ import type { BedrockHealthReport } from './health.js'
33
+ import { report, reportForError } from './health.js'
35
34
  import { assertModelReachable, isAnthropicServedModel } from './model-reachability.js'
36
35
  import type { BedrockConfig } from './types.js'
37
36
 
38
- function extractSystemBlocks(messages: ChatCompletionParams['messages']): SystemContentBlock[] {
39
- return messages
40
- .filter((m) => m.role === 'system')
41
- .map((m) => ({ text: typeof m.content === 'string' ? m.content : '' }))
37
+ /**
38
+ * The marker that asks this wire to cache everything before it.
39
+ *
40
+ * Caching is not a flag on the request here — it is a block spliced into
41
+ * the content, and a request without one is uncached however the caller
42
+ * configured it. That is why nothing was cached before: the driver read
43
+ * the cache counters faithfully and never emitted a marker, so the
44
+ * counters were honestly zero and a caller could not tell "caching does
45
+ * not help this workload" from "caching was never asked for".
46
+ *
47
+ * `default` is the only member of the wire's cache-point enum. `ttl` is
48
+ * deliberately not set: omitting it takes the service's own default,
49
+ * and an extended TTL is priced differently, so choosing one is a cost
50
+ * decision rather than a translation.
51
+ */
52
+ const CACHE_POINT: { cachePoint: CachePointBlock } = { cachePoint: { type: 'default' } }
53
+
54
+ /**
55
+ * System text, with a cache point after the last STATIC block.
56
+ *
57
+ * Position is the whole point, not presence. The runtime tags its static
58
+ * segment `'cache'` and its per-run dynamic segment `'ephemeral'`, and a
59
+ * marker appended at the end of the array would put the dynamic text
60
+ * inside the cached prefix — which changes every run, so every read would
61
+ * miss and every write would be paid for. A caller would see cache writes
62
+ * and no reads, which looks like a cold cache forever.
63
+ *
64
+ * No static block means no system breakpoint, rather than a breakpoint in
65
+ * an arbitrary place.
66
+ */
67
+ function extractSystemBlocks(
68
+ messages: ChatCompletionParams['messages'],
69
+ cachingEnabled = false,
70
+ ): SystemContentBlock[] {
71
+ const blocks: SystemContentBlock[] = []
72
+ let lastStatic = -1
73
+
74
+ for (const m of messages) {
75
+ if (m.role !== 'system') continue
76
+ blocks.push({ text: typeof m.content === 'string' ? m.content : '' })
77
+ if (m.cacheHint === 'cache') lastStatic = blocks.length - 1
78
+ }
79
+
80
+ if (cachingEnabled && lastStatic >= 0) blocks.splice(lastStatic + 1, 0, CACHE_POINT)
81
+
82
+ return blocks
83
+ }
84
+
85
+ /**
86
+ * The final cache point: after the last content block of the last
87
+ * non-empty message.
88
+ *
89
+ * An iteration only appends messages, so caching the whole conversation
90
+ * prefix here is what makes the NEXT turn read its history at cache rates
91
+ * — which on a long run is the largest single cost lever available.
92
+ */
93
+ function applyMessageCachePoint(messages: BedrockMessage[]): void {
94
+ for (let i = messages.length - 1; i >= 0; i--) {
95
+ const content = messages[i]?.content
96
+ if (content && content.length > 0) {
97
+ content.push(CACHE_POINT)
98
+ return
99
+ }
100
+ }
42
101
  }
43
102
 
44
103
  function toBedrockRole(role: string): ConversationRole {
@@ -138,7 +197,10 @@ function extractToolNamesFromHistory(messages: ChatCompletionParams['messages'])
138
197
  return Array.from(names)
139
198
  }
140
199
 
141
- export function toBedrockToolConfig(params: ChatCompletionParams): ToolConfiguration | undefined {
200
+ export function toBedrockToolConfig(
201
+ params: ChatCompletionParams,
202
+ cachingEnabled = false,
203
+ ): ToolConfiguration | undefined {
142
204
  // 'none' means the model must not call a tool. This used to map to the
143
205
  // wire's 'auto', which means it MAY — the opposite, and silent. No wire
144
206
  // format lets a model call a tool it was never given, so send none.
@@ -168,6 +230,16 @@ export function toBedrockToolConfig(params: ChatCompletionParams): ToolConfigura
168
230
  }) as Tool,
169
231
  )
170
232
 
233
+ // After the schemas, so the tool block — the largest fixed prefix on
234
+ // a tool-using run, and the one that changes least — is cached.
235
+ //
236
+ // The reconstruction path below deliberately does not get one. Those
237
+ // are placeholder specs minted to keep the wire happy when history
238
+ // references a tool the caller no longer passes; their descriptions
239
+ // are the literal string `(completed)`, so caching them would pin a
240
+ // prefix that is not the caller's tool set.
241
+ if (cachingEnabled) tools.push(CACHE_POINT)
242
+
171
243
  const toolChoice = formatToolChoice(params.toolChoice)
172
244
  return { tools, toolChoice }
173
245
  }
@@ -202,6 +274,25 @@ function formatToolChoice(tc?: ToolChoice) {
202
274
  return { auto: {} }
203
275
  }
204
276
 
277
+ /**
278
+ * The failure Bedrock reported as a stream EVENT rather than as a throw.
279
+ *
280
+ * Several post-handshake failures arrive as members of the output union, after
281
+ * a 200. Ignoring them makes a throttled or failed stream look like a clean
282
+ * EOF — which is why this is read on the request path, and why the health probe
283
+ * reads it through the same function rather than through a second copy that
284
+ * could drift from this one.
285
+ */
286
+ export function streamFailureIn(event: ConverseStreamOutput): unknown {
287
+ return (
288
+ ('internalServerException' in event ? event.internalServerException : undefined) ??
289
+ ('modelStreamErrorException' in event ? event.modelStreamErrorException : undefined) ??
290
+ ('validationException' in event ? event.validationException : undefined) ??
291
+ ('throttlingException' in event ? event.throttlingException : undefined) ??
292
+ ('serviceUnavailableException' in event ? event.serviceUnavailableException : undefined)
293
+ )
294
+ }
295
+
205
296
  interface RawBedrockUsage {
206
297
  inputTokens?: number
207
298
  outputTokens?: number
@@ -298,9 +389,26 @@ export class BedrockProvider implements LLMProvider {
298
389
  // reason and the fix, rather than as an opaque validation error from
299
390
  // a service that was asked for something this wire does not carry.
300
391
  assertModelReachable(params.model)
301
- const system = extractSystemBlocks(params.messages)
392
+
393
+ // The runtime asks for caching on every iteration by setting
394
+ // `cacheControl`. Honouring it costs three cache points — tools tail,
395
+ // static-system tail, last message — and the prompt is assembled
396
+ // tools → system → messages, so each later point also covers every
397
+ // section before it.
398
+ //
399
+ // Gated on the model family for the reason `isAnthropicServedModel`
400
+ // already exists: Converse is a multi-vendor wire, and prompt caching
401
+ // is a property of the models on it rather than of the wire. The gate
402
+ // fails toward today's behaviour — a model outside it sends exactly
403
+ // the bytes it sends now, uncached — because the alternative failure
404
+ // is a rejected request, which costs a caller a working integration
405
+ // rather than a discount they never had.
406
+ const cachingEnabled = params.cacheControl !== undefined && isAnthropicServedModel(params.model)
407
+
408
+ const system = extractSystemBlocks(params.messages, cachingEnabled)
302
409
  const messages = toBedrockMessages(params.messages)
303
- const toolConfig = toBedrockToolConfig(params)
410
+ if (cachingEnabled) applyMessageCachePoint(messages)
411
+ const toolConfig = toBedrockToolConfig(params, cachingEnabled)
304
412
 
305
413
  const inferenceConfig: Record<string, unknown> = {}
306
414
  if (params.maxTokens !== undefined) inferenceConfig.maxTokens = params.maxTokens
@@ -355,12 +463,7 @@ export class BedrockProvider implements LLMProvider {
355
463
  // Bedrock reports several post-handshake failures as UNION
356
464
  // EVENTS, not thrown exceptions. Ignoring these members makes a
357
465
  // throttled or failed stream look like a clean EOF.
358
- const streamFailure =
359
- ('internalServerException' in event ? event.internalServerException : undefined) ??
360
- ('modelStreamErrorException' in event ? event.modelStreamErrorException : undefined) ??
361
- ('validationException' in event ? event.validationException : undefined) ??
362
- ('throttlingException' in event ? event.throttlingException : undefined) ??
363
- ('serviceUnavailableException' in event ? event.serviceUnavailableException : undefined)
466
+ const streamFailure = streamFailureIn(event)
364
467
  if (streamFailure) {
365
468
  throw providerVendorError({
366
469
  providerId: 'bedrock',
@@ -459,10 +562,40 @@ export class BedrockProvider implements LLMProvider {
459
562
  }
460
563
  }
461
564
 
565
+ /**
566
+ * The menu this driver offers.
567
+ *
568
+ * Every Claude id here is the `bedrock-runtime` Model ID from the vendor's
569
+ * own model card, and that is a correction rather than a preference: the
570
+ * two entries this list used to carry — `anthropic.claude-sonnet-4-20250514`
571
+ * and `anthropic.claude-haiku-4-20250514` — are ids `assertModelReachable`
572
+ * refuses, so an operator picking either off the menu got a throw before any
573
+ * request was built. A catalogue its own request path rejects is worse than
574
+ * a short one.
575
+ *
576
+ * The card for Claude Haiku 4.5 is what settles that the predicate is right
577
+ * rather than too strict. It lists the SAME model under two ids on two
578
+ * endpoints: `anthropic.claude-haiku-4-5-20251001-v1:0` on
579
+ * `bedrock-runtime`, and the unversioned `anthropic.claude-haiku-4-5` on
580
+ * `bedrock-mantle`, at a different URL. The bare form is a real id for the
581
+ * endpoint this driver does not speak — which is exactly the claim
582
+ * `model-reachability.ts` makes, stated by the vendor in one table.
583
+ *
584
+ * "Claude Haiku 4" was never a model. The Haiku line goes 3.5 to 4.5, and
585
+ * the id that shipped here carried Sonnet 4's launch date.
586
+ *
587
+ * `inputPrice`/`outputPrice` are per million tokens and are DISPLAY data:
588
+ * `resolveModelPricing` in `@namzu/sdk` has no `bedrock` vendor, so no run
589
+ * is costed from them. The Haiku 4.5 rates are the reviewed ones from that
590
+ * package's own `rates.source.json`. AWS publishes Bedrock's on-demand rates
591
+ * on a page that renders them client-side, so they could not be read here,
592
+ * and if Bedrock diverges from the vendor's list price this row is wrong in
593
+ * that direction only.
594
+ */
462
595
  async listModels(): Promise<ModelInfo[]> {
463
596
  return [
464
597
  {
465
- id: 'anthropic.claude-sonnet-4-20250514',
598
+ id: 'anthropic.claude-sonnet-4-20250514-v1:0',
466
599
  name: 'Claude Sonnet 4 (Bedrock)',
467
600
  contextWindow: 200_000,
468
601
  maxOutputTokens: 64_000,
@@ -472,12 +605,12 @@ export class BedrockProvider implements LLMProvider {
472
605
  supportsStreaming: true,
473
606
  },
474
607
  {
475
- id: 'anthropic.claude-haiku-4-20250514',
476
- name: 'Claude Haiku 4 (Bedrock)',
608
+ id: 'anthropic.claude-haiku-4-5-20251001-v1:0',
609
+ name: 'Claude Haiku 4.5 (Bedrock)',
477
610
  contextWindow: 200_000,
478
611
  maxOutputTokens: 64_000,
479
- inputPrice: 0.8,
480
- outputPrice: 4.0,
612
+ inputPrice: 1.0,
613
+ outputPrice: 5.0,
481
614
  supportsToolUse: true,
482
615
  supportsStreaming: true,
483
616
  },
@@ -494,19 +627,92 @@ export class BedrockProvider implements LLMProvider {
494
627
  ]
495
628
  }
496
629
 
497
- async healthCheck(): Promise<boolean> {
630
+ /**
631
+ * Can this driver serve `model` right now?
632
+ *
633
+ * One bit, and deliberately still one bit. Widening the return type would
634
+ * break every caller that writes `if (await provider.healthCheck())`
635
+ * SILENTLY — the code keeps compiling and starts always passing, because a
636
+ * result object is truthy. {@link doctorCheck} is the same probe with its
637
+ * reasoning intact, and is what to call when the answer matters.
638
+ *
639
+ * `model` is required in practice though the type says optional: this
640
+ * driver's config holds no model, so with nothing passed there is nothing to
641
+ * probe, and the answer is `false` for the stated reason `no-model` rather
642
+ * than for a guessed one. It used to probe a hardcoded id instead, which is
643
+ * how it came to send an id its own reachability rule rejects and report the
644
+ * refusal as an outage.
645
+ */
646
+ async healthCheck(model?: string): Promise<boolean> {
647
+ return (await this.doctorCheck(model)).status === 'pass'
648
+ }
649
+
650
+ /**
651
+ * The health probe, with the reason it reached its verdict.
652
+ *
653
+ * Probes with `ConverseStream` rather than `Converse` because that is the
654
+ * command {@link chatStream} sends. They are separate IAM actions —
655
+ * `bedrock:InvokeModelWithResponseStream` and `bedrock:InvokeModel` — so a
656
+ * probe on the other one can pass under a policy every real call fails
657
+ * under, which is a green check about a request nobody makes.
658
+ *
659
+ * It costs one inference of one token. Nothing cheaper on the runtime client
660
+ * establishes credentials, region and model reachability together, and the
661
+ * alternatives establish them for a permission the request path does not
662
+ * use.
663
+ */
664
+ async doctorCheck(model?: string): Promise<BedrockHealthReport> {
665
+ if (model === undefined || model.trim() === '') {
666
+ return report(
667
+ 'no-model',
668
+ 'No model id was given and this driver holds none in its config, so nothing was probed. This is not a report that Bedrock is unhealthy.',
669
+ )
670
+ }
671
+
672
+ const startedAt = Date.now()
673
+ const elapsed = () => Date.now() - startedAt
674
+
675
+ // The same rule the request path applies, applied here. Skipping it is
676
+ // how the previous check came to send an id this driver classifies as
677
+ // uninvokable and then read the service's refusal as an outage.
498
678
  try {
499
- const command = new ConverseCommand({
500
- modelId: 'anthropic.claude-haiku-4-20250514',
679
+ assertModelReachable(model)
680
+ } catch (err) {
681
+ return report('unreachable-model', err instanceof Error ? err.message : String(err), {
682
+ model,
683
+ durationMs: elapsed(),
684
+ })
685
+ }
686
+
687
+ try {
688
+ const command = new ConverseStreamCommand({
689
+ modelId: model,
501
690
  messages: [{ role: 'user', content: [{ text: 'hi' }] }],
502
691
  inferenceConfig: { maxTokens: 1 },
503
692
  })
504
- const response = await this.client.send(command, {
505
- requestTimeout: 5000,
506
- })
507
- return response.$metadata.httpStatusCode === 200
508
- } catch {
509
- return false
693
+ const response = await this.client.send(command, { requestTimeout: 5000 })
694
+
695
+ // No branch on the status code: this SDK throws for a non-2xx, so a
696
+ // handshake that returns at all returned success. A branch production
697
+ // cannot enter is one only a fixture unlike production could test.
698
+ if (!response.stream) {
699
+ return report('service', 'bedrock accepted the request and returned no stream body', {
700
+ model,
701
+ durationMs: elapsed(),
702
+ })
703
+ }
704
+
705
+ // The handshake is a 200 even when the model then refuses, because
706
+ // Bedrock reports those failures as members of the output union.
707
+ // Reading only the status would call that a pass.
708
+ for await (const event of response.stream as AsyncIterable<ConverseStreamOutput>) {
709
+ const failure = streamFailureIn(event)
710
+ if (failure) return reportForError(failure, model, elapsed())
711
+ }
712
+
713
+ return report('ok', `bedrock served ${model}`, { model, durationMs: elapsed() })
714
+ } catch (err) {
715
+ return reportForError(err, model, elapsed())
510
716
  }
511
717
  }
512
718
  }