@namzu/bedrock 4.0.1 → 5.0.1
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/CHANGELOG.md +81 -0
- package/README.md +44 -51
- package/dist/client.d.ts +75 -3
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +216 -27
- package/dist/client.js.map +1 -1
- package/dist/health.d.ts +77 -0
- package/dist/health.d.ts.map +1 -0
- package/dist/health.js +131 -0
- package/dist/health.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/client.ts +238 -32
- package/src/health.ts +194 -0
- package/src/index.ts +1 -0
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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-
|
|
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
|
|
480
|
-
outputPrice:
|
|
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
|
-
|
|
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
|
-
|
|
500
|
-
|
|
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
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
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
|
}
|
package/src/health.ts
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
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
|
+
|
|
17
|
+
import type { DoctorCheckResult } from '@namzu/sdk'
|
|
18
|
+
import { providerVendorError } from '@namzu/sdk'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Why the probe reached the verdict it did.
|
|
22
|
+
*
|
|
23
|
+
* Named for what an operator would do next, which is why `credentials` and
|
|
24
|
+
* `unreachable-model` are separate from `refused`: the first is a key to
|
|
25
|
+
* rotate, the second an id to change, the third a request the service looked
|
|
26
|
+
* at and would not take.
|
|
27
|
+
*/
|
|
28
|
+
export type BedrockHealthReason =
|
|
29
|
+
/** The model answered. */
|
|
30
|
+
| 'ok'
|
|
31
|
+
/** Nothing was probed: no model id was given and this driver holds none. */
|
|
32
|
+
| 'no-model'
|
|
33
|
+
/** The driver's own reachability rule refuses this id; nothing was sent. */
|
|
34
|
+
| 'unreachable-model'
|
|
35
|
+
/** No AWS credentials could be resolved on this machine. Never left it. */
|
|
36
|
+
| 'no-credentials'
|
|
37
|
+
/** AWS answered and rejected the credential or its permissions. */
|
|
38
|
+
| 'credentials'
|
|
39
|
+
/** The id is well-formed but this region serves no such model. */
|
|
40
|
+
| 'unknown-model'
|
|
41
|
+
/** The service looked at the request and refused it. */
|
|
42
|
+
| 'refused'
|
|
43
|
+
/** Reached, authenticated, and rate limited. The probe did not complete. */
|
|
44
|
+
| 'throttled'
|
|
45
|
+
/** The service answered and could not serve the request. */
|
|
46
|
+
| 'service'
|
|
47
|
+
/** Nothing was learned: the request did not get an answer. */
|
|
48
|
+
| 'unreachable-service'
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* A `DoctorCheckResult` with the reason kept.
|
|
52
|
+
*
|
|
53
|
+
* Extends rather than replaces so it satisfies `LLMProvider.doctorCheck`
|
|
54
|
+
* unchanged: `runDoctor()` reads `status` and never sees the extra field, and a
|
|
55
|
+
* caller holding the concrete driver reads `reason` without a cast.
|
|
56
|
+
*/
|
|
57
|
+
export interface BedrockHealthReport extends DoctorCheckResult {
|
|
58
|
+
readonly reason: BedrockHealthReason
|
|
59
|
+
/** The id that was probed. Absent only when nothing was probed. */
|
|
60
|
+
readonly model?: string
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* AWS models its failures as distinct exception CLASSES rather than as status
|
|
65
|
+
* codes, and the class is the only thing that separates the cases an operator
|
|
66
|
+
* acts on differently.
|
|
67
|
+
*
|
|
68
|
+
* The SDK's `providerVendorError` deliberately flattens these into six kinds
|
|
69
|
+
* for the request path, where the question is "retry, compact, or give up".
|
|
70
|
+
* That is the right shape there and the wrong one here: it merges
|
|
71
|
+
* `ResourceNotFoundException` ("no such model in this region") with
|
|
72
|
+
* `ValidationException` ("the request was invalid") into `bad_request`, and
|
|
73
|
+
* those are the two answers a health check exists to tell apart. So the class
|
|
74
|
+
* is read here, where the raw error is still in hand.
|
|
75
|
+
*
|
|
76
|
+
* Matched on `name` rather than with `instanceof`: the exception classes are
|
|
77
|
+
* re-exported by every minor of the AWS SDK, and a caller running two copies of
|
|
78
|
+
* `@aws-sdk/client-bedrock-runtime` in one tree would fail every `instanceof`
|
|
79
|
+
* against errors that are otherwise correct.
|
|
80
|
+
*/
|
|
81
|
+
const REASON_BY_EXCEPTION: ReadonlyArray<readonly [RegExp, BedrockHealthReason]> = [
|
|
82
|
+
// Client-side, before a packet leaves: the credential chain found nothing.
|
|
83
|
+
// This is a definite answer about the machine, not a failure to reach AWS,
|
|
84
|
+
// and filing it as unreachable would tell someone with no credentials at
|
|
85
|
+
// all to go and check their network.
|
|
86
|
+
[/CredentialsProviderError|CredentialsError/i, 'no-credentials'],
|
|
87
|
+
[/AccessDenied|UnrecognizedClient|InvalidSignature|Unauthorized|Forbidden/i, 'credentials'],
|
|
88
|
+
[/ResourceNotFound/i, 'unknown-model'],
|
|
89
|
+
[/ThrottlingException|TooManyRequests|ServiceQuotaExceeded/i, 'throttled'],
|
|
90
|
+
[
|
|
91
|
+
/ServiceUnavailable|InternalServer|ModelNotReady|ModelTimeout|ModelStreamError|ModelError/i,
|
|
92
|
+
'service',
|
|
93
|
+
],
|
|
94
|
+
[/Validation/i, 'refused'],
|
|
95
|
+
]
|
|
96
|
+
|
|
97
|
+
const STATUS_BY_REASON: Readonly<Record<BedrockHealthReason, DoctorCheckResult['status']>> = {
|
|
98
|
+
ok: 'pass',
|
|
99
|
+
'no-model': 'skipped',
|
|
100
|
+
'unreachable-model': 'fail',
|
|
101
|
+
'no-credentials': 'fail',
|
|
102
|
+
credentials: 'fail',
|
|
103
|
+
'unknown-model': 'fail',
|
|
104
|
+
refused: 'fail',
|
|
105
|
+
// Reached, authenticated, throttled. The service is up, so this is not a
|
|
106
|
+
// failure of the integration — but the probe did not complete, so it is
|
|
107
|
+
// not a pass either, and reporting it as one would let a caller start
|
|
108
|
+
// sending traffic on the strength of a request that never ran.
|
|
109
|
+
throttled: 'warn',
|
|
110
|
+
service: 'fail',
|
|
111
|
+
// The check did not answer. Distinct from `fail` because nothing is known:
|
|
112
|
+
// telling an operator on broken wifi that Bedrock is down sends them to
|
|
113
|
+
// the wrong place.
|
|
114
|
+
'unreachable-service': 'inconclusive',
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const REMEDIATION_BY_REASON: Readonly<Partial<Record<BedrockHealthReason, string>>> = {
|
|
118
|
+
'no-credentials':
|
|
119
|
+
'Pass accessKeyId/secretAccessKey to the driver, or make the AWS default credential chain resolve here (env, shared config, SSO, or an instance role).',
|
|
120
|
+
credentials:
|
|
121
|
+
'Check the credential is current and that its IAM policy allows bedrock:InvokeModelWithResponseStream on this model in this region.',
|
|
122
|
+
'unknown-model':
|
|
123
|
+
'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.',
|
|
124
|
+
refused:
|
|
125
|
+
'The service rejected the request itself. A model served only through an inference profile refuses the bare id — try the profile-prefixed form.',
|
|
126
|
+
throttled: 'Retry. The service is reachable and the credential authenticated.',
|
|
127
|
+
service: 'Nothing to do here; the service could not serve the request. Retry later.',
|
|
128
|
+
'unreachable-service':
|
|
129
|
+
'Nothing was learned about the service. Check network reachability of the Bedrock endpoint for this region before changing any credential.',
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The reason an AWS failure implies; "nothing was learned" when unrecognised. */
|
|
133
|
+
export function reasonForError(err: unknown): BedrockHealthReason {
|
|
134
|
+
// Read off the object rather than gated on an instanceof check. Bedrock
|
|
135
|
+
// reports several failures as members of the stream's output union, and a
|
|
136
|
+
// deserialiser that hands one back as a plain shape would otherwise fall all
|
|
137
|
+
// the way through to "nothing was learned" while naming its own class.
|
|
138
|
+
const raw = (err as { name?: unknown } | null)?.name
|
|
139
|
+
const name = typeof raw === 'string' ? raw : ''
|
|
140
|
+
for (const [pattern, reason] of REASON_BY_EXCEPTION) {
|
|
141
|
+
if (pattern.test(name)) return reason
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// A region that was never configured also never leaves the machine, and the
|
|
145
|
+
// AWS SDK reports it as a plain `Error`, so there is no class to match.
|
|
146
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
147
|
+
if (/region is missing|missing region/i.test(message)) return 'no-credentials'
|
|
148
|
+
|
|
149
|
+
return 'unreachable-service'
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Build the report for a failure.
|
|
154
|
+
*
|
|
155
|
+
* The vendor error's message is taken from `providerVendorError` rather than
|
|
156
|
+
* from `err.message`, and that is not stylistic. AWS builds an exception
|
|
157
|
+
* message from the response body, so a credential the service echoed back is
|
|
158
|
+
* already inside `err.message` before this code sees it; `providerVendorError`
|
|
159
|
+
* is the one place in this estate that strips it. The class name is read off
|
|
160
|
+
* the raw error first — a name is not a body — and then the raw error is
|
|
161
|
+
* dropped.
|
|
162
|
+
*/
|
|
163
|
+
export function reportForError(
|
|
164
|
+
err: unknown,
|
|
165
|
+
model: string,
|
|
166
|
+
durationMs: number,
|
|
167
|
+
): BedrockHealthReport {
|
|
168
|
+
const reason = reasonForError(err)
|
|
169
|
+
const safe = providerVendorError({ providerId: 'bedrock', error: err })
|
|
170
|
+
return {
|
|
171
|
+
status: STATUS_BY_REASON[reason],
|
|
172
|
+
reason,
|
|
173
|
+
model,
|
|
174
|
+
message: safe.message,
|
|
175
|
+
...(REMEDIATION_BY_REASON[reason] ? { remediation: REMEDIATION_BY_REASON[reason] } : {}),
|
|
176
|
+
durationMs,
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** The report for a reason that needs no vendor error to describe it. */
|
|
181
|
+
export function report(
|
|
182
|
+
reason: BedrockHealthReason,
|
|
183
|
+
message: string,
|
|
184
|
+
extra: { readonly model?: string; readonly durationMs?: number } = {},
|
|
185
|
+
): BedrockHealthReport {
|
|
186
|
+
return {
|
|
187
|
+
status: STATUS_BY_REASON[reason],
|
|
188
|
+
reason,
|
|
189
|
+
message,
|
|
190
|
+
...(extra.model !== undefined ? { model: extra.model } : {}),
|
|
191
|
+
...(REMEDIATION_BY_REASON[reason] ? { remediation: REMEDIATION_BY_REASON[reason] } : {}),
|
|
192
|
+
...(extra.durationMs !== undefined ? { durationMs: extra.durationMs } : {}),
|
|
193
|
+
}
|
|
194
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -23,4 +23,5 @@ export function registerBedrock(options?: RegisterOptions): void {
|
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
export { BEDROCK_CAPABILITIES, BedrockProvider } from './client.js'
|
|
26
|
+
export type { BedrockHealthReason, BedrockHealthReport } from './health.js'
|
|
26
27
|
export type { BedrockConfig, BedrockProviderConfig } from './types.js'
|