@wmaurer/effect-ai-amazon-bedrock 0.3.0 → 0.4.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.
@@ -377,6 +377,79 @@ export class CitationsContentBlock extends Schema.Class<CitationsContentBlock>(
377
377
  citations: Schema.optional(Schema.Array(Citation))
378
378
  }) {}
379
379
 
380
+ /**
381
+ * The role a guarded text block plays in a contextual grounding check.
382
+ *
383
+ * **Details**
384
+ *
385
+ * `grounding_source` marks the documents an answer must be grounded in,
386
+ * `query` marks the question, and `guard_content` marks text the guardrail
387
+ * evaluates without a grounding role.
388
+ *
389
+ * @stability unstable
390
+ * @category schemas
391
+ * @since 0.4.0
392
+ */
393
+ export const GuardrailConverseContentQualifier = Schema.Literals(["grounding_source", "query", "guard_content"])
394
+
395
+ /**
396
+ * Text that the guardrail evaluates.
397
+ *
398
+ * **Details**
399
+ *
400
+ * `qualifiers` uses `optionalKey` for the same reason as
401
+ * `CachePointBlock.ttl`: the block is built from `Prompt` provider options,
402
+ * whose values must satisfy `Schema.Json`.
403
+ *
404
+ * @stability unstable
405
+ * @category schemas
406
+ * @since 0.4.0
407
+ */
408
+ export class GuardrailConverseTextBlock extends Schema.Class<GuardrailConverseTextBlock>(
409
+ makeIdentifier("GuardrailConverseTextBlock")
410
+ )({
411
+ text: Schema.String,
412
+ qualifiers: Schema.optionalKey(Schema.Array(GuardrailConverseContentQualifier))
413
+ }) {}
414
+
415
+ /**
416
+ * An image that the guardrail evaluates.
417
+ *
418
+ * **Details**
419
+ *
420
+ * The Smithy model allows only png and jpeg, and only `bytes` as the source.
421
+ * `bytes` is a Smithy blob, which the JSON protocol carries as a base64
422
+ * string.
423
+ *
424
+ * @stability unstable
425
+ * @category schemas
426
+ * @since 0.4.0
427
+ */
428
+ export class GuardrailConverseImageBlock extends Schema.Class<GuardrailConverseImageBlock>(
429
+ makeIdentifier("GuardrailConverseImageBlock")
430
+ )({
431
+ format: Schema.Literals(["png", "jpeg"]),
432
+ source: Schema.Struct({ bytes: Schema.String })
433
+ }) {}
434
+
435
+ /**
436
+ * Content that the guardrail evaluates, in a message or in the system prompt.
437
+ *
438
+ * **Details**
439
+ *
440
+ * AWS models `GuardrailConverseContentBlock` as a UNION, so both members are
441
+ * optional here. Once a request holds one `guardContent` block, the guardrail
442
+ * evaluates only the guarded blocks. The model still reads every block.
443
+ *
444
+ * @stability unstable
445
+ * @category schemas
446
+ * @since 0.4.0
447
+ */
448
+ export const GuardrailConverseContentBlock = Schema.Struct({
449
+ text: Schema.optional(GuardrailConverseTextBlock),
450
+ image: Schema.optional(GuardrailConverseImageBlock)
451
+ })
452
+
380
453
  /**
381
454
  * A text content block within a Converse message.
382
455
  *
@@ -401,7 +474,8 @@ export const ContentBlock = Schema.Struct({
401
474
  image: Schema.optional(ImageBlock),
402
475
  document: Schema.optional(DocumentBlock),
403
476
  cachePoint: Schema.optional(CachePointBlock),
404
- citationsContent: Schema.optional(CitationsContentBlock)
477
+ citationsContent: Schema.optional(CitationsContentBlock),
478
+ guardContent: Schema.optional(GuardrailConverseContentBlock)
405
479
  })
406
480
 
407
481
  /**
@@ -421,8 +495,9 @@ export class Message extends Schema.Class<Message>(makeIdentifier("Message"))({
421
495
  *
422
496
  * **Details**
423
497
  *
424
- * AWS models `SystemContentBlock` as a UNION, so both members are optional; a
425
- * block carries either the system text or a cache point.
498
+ * AWS models `SystemContentBlock` as a UNION, so every member is optional; a
499
+ * block carries the system text, guarded system text, or a cache point. The
500
+ * guardrail evaluates the system prompt only where it is guarded.
426
501
  *
427
502
  * @stability unstable
428
503
  * @category schemas
@@ -430,7 +505,8 @@ export class Message extends Schema.Class<Message>(makeIdentifier("Message"))({
430
505
  */
431
506
  export const SystemContentBlock = Schema.Struct({
432
507
  text: Schema.optional(Schema.String),
433
- cachePoint: Schema.optional(CachePointBlock)
508
+ cachePoint: Schema.optional(CachePointBlock),
509
+ guardContent: Schema.optional(GuardrailConverseContentBlock)
434
510
  })
435
511
 
436
512
  /**
@@ -578,7 +654,80 @@ export const OutputConfig = Schema.Struct({
578
654
  })
579
655
 
580
656
  /**
581
- * The request payload for the Converse and ConverseStream operations.
657
+ * Whether Bedrock returns the guardrail trace.
658
+ *
659
+ * **Details**
660
+ *
661
+ * Bedrock's default is `disabled`. The trace holds the text that a policy
662
+ * matched, unmasked. `enabled_full` is in the Smithy model, and AWS does not
663
+ * document it.
664
+ *
665
+ * @stability unstable
666
+ * @category schemas
667
+ * @since 0.4.0
668
+ */
669
+ export const GuardrailTrace = Schema.Literals(["enabled", "disabled", "enabled_full"])
670
+
671
+ /**
672
+ * How ConverseStream applies a guardrail to the streamed output.
673
+ *
674
+ * **Details**
675
+ *
676
+ * `sync`, Bedrock's default, holds back each chunk until the guardrail has
677
+ * evaluated it. `async` sends chunks at once, so text that the guardrail
678
+ * later blocks can reach the caller, and masking does not apply.
679
+ *
680
+ * @stability unstable
681
+ * @category schemas
682
+ * @since 0.4.0
683
+ */
684
+ export const GuardrailStreamProcessingMode = Schema.Literals(["sync", "async"])
685
+
686
+ // Smithy gives the identifier and the version `default: ""`, and Bedrock cannot
687
+ // apply a guardrail without them, so both are required here.
688
+ const guardrailFields = {
689
+ guardrailIdentifier: Schema.String,
690
+ guardrailVersion: Schema.String,
691
+ trace: Schema.optionalKey(GuardrailTrace)
692
+ }
693
+
694
+ /**
695
+ * The guardrail of a Converse request.
696
+ *
697
+ * **Details**
698
+ *
699
+ * `guardrailIdentifier` is the guardrail's ID or ARN, and `guardrailVersion`
700
+ * is a version number or `DRAFT`. Converse has no `streamProcessingMode`, so
701
+ * the member is typed `never`: a ConverseStream request does not fit Converse
702
+ * until the mode is removed.
703
+ *
704
+ * @stability unstable
705
+ * @category schemas
706
+ * @since 0.4.0
707
+ */
708
+ export class GuardrailConfiguration extends Schema.Class<GuardrailConfiguration>(
709
+ makeIdentifier("GuardrailConfiguration")
710
+ )({
711
+ ...guardrailFields,
712
+ streamProcessingMode: Schema.optionalKey(Schema.Never)
713
+ }) {}
714
+
715
+ /**
716
+ * The guardrail of a ConverseStream request.
717
+ *
718
+ * @stability unstable
719
+ * @category schemas
720
+ * @since 0.4.0
721
+ */
722
+ export class GuardrailStreamConfiguration extends Schema.Class<GuardrailStreamConfiguration>(
723
+ makeIdentifier("GuardrailStreamConfiguration")
724
+ )({
725
+ ...guardrailFields,
726
+ streamProcessingMode: Schema.optionalKey(GuardrailStreamProcessingMode)
727
+ }) {}
728
+
729
+ /**
730
+ * The request payload for the Converse operation.
582
731
  *
583
732
  * **Details**
584
733
  *
@@ -602,9 +751,30 @@ export class ConverseRequest extends Schema.Class<ConverseRequest>(makeIdentifie
602
751
  toolConfig: Schema.optionalKey(ToolConfiguration),
603
752
  inferenceConfig: Schema.optional(InferenceConfiguration),
604
753
  outputConfig: Schema.optionalKey(OutputConfig),
605
- additionalModelRequestFields: Schema.optional(Schema.Record(Schema.String, Schema.Unknown))
754
+ additionalModelRequestFields: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
755
+ guardrailConfig: Schema.optional(GuardrailConfiguration)
606
756
  }) {}
607
757
 
758
+ /**
759
+ * The request payload for the ConverseStream operation.
760
+ *
761
+ * **Details**
762
+ *
763
+ * The members of `ConverseRequest`, with the guardrail of a stream, which
764
+ * adds `streamProcessingMode`. That member is the only difference between the
765
+ * two operations.
766
+ *
767
+ * @stability unstable
768
+ * @category schemas
769
+ * @since 0.4.0
770
+ */
771
+ export class ConverseStreamRequest
772
+ extends Schema.Class<ConverseStreamRequest>(makeIdentifier("ConverseStreamRequest"))({
773
+ ...ConverseRequest.fields,
774
+ guardrailConfig: Schema.optional(GuardrailStreamConfiguration)
775
+ })
776
+ {}
777
+
608
778
  // =============================================================================
609
779
  // Response
610
780
  // =============================================================================
@@ -634,6 +804,12 @@ export class ConverseMetrics extends Schema.Class<ConverseMetrics>(makeIdentifie
634
804
  /**
635
805
  * The response from a successful Converse call.
636
806
  *
807
+ * **Details**
808
+ *
809
+ * `trace` is kept as the JSON Bedrock sent. Its Smithy tree has about 90
810
+ * shapes that AWS still extends, and a struct would drop every member added
811
+ * later.
812
+ *
637
813
  * @stability unstable
638
814
  * @category schemas
639
815
  * @since 0.1.0
@@ -642,7 +818,8 @@ export class ConverseResponse extends Schema.Class<ConverseResponse>(makeIdentif
642
818
  output: ConverseOutput,
643
819
  usage: TokenUsage,
644
820
  stopReason: StopReason,
645
- metrics: Schema.optional(ConverseMetrics)
821
+ metrics: Schema.optional(ConverseMetrics),
822
+ trace: Schema.optional(Schema.MutableJson)
646
823
  }) {}
647
824
 
648
825
  // =============================================================================
@@ -823,7 +1000,13 @@ export class MessageStopEvent extends Schema.Class<MessageStopEvent>(
823
1000
  }) {}
824
1001
 
825
1002
  /**
826
- * The trailing metadata event for a Converse stream, carrying usage and metrics.
1003
+ * The trailing metadata event for a Converse stream, carrying usage, metrics,
1004
+ * and the trace.
1005
+ *
1006
+ * **Details**
1007
+ *
1008
+ * This is the only stream event that carries the trace. It is kept as JSON,
1009
+ * as in `ConverseResponse`.
827
1010
  *
828
1011
  * @stability unstable
829
1012
  * @category schemas
@@ -833,7 +1016,8 @@ export class ConverseStreamMetadataEvent extends Schema.Class<ConverseStreamMeta
833
1016
  makeIdentifier("ConverseStreamMetadataEvent")
834
1017
  )({
835
1018
  usage: TokenUsage,
836
- metrics: Schema.optional(ConverseMetrics)
1019
+ metrics: Schema.optional(ConverseMetrics),
1020
+ trace: Schema.optional(Schema.MutableJson)
837
1021
  }) {}
838
1022
 
839
1023
  const ExceptionBody = Schema.Struct({
@@ -114,10 +114,10 @@ export const mapCredentialProviderError =
114
114
  // with the exception type carried in the `x-amzn-errortype` header — NOT
115
115
  // Anthropic's nested `{ error: { type, message } }`. Schema-guarded so a
116
116
  // non-conforming body safely yields `undefined`.
117
- const BedrockErrorBody = Schema.Struct({
117
+ const BedrockErrorBody = Schema.fromJsonString(Schema.Struct({
118
118
  message: Schema.optional(Schema.String),
119
119
  Message: Schema.optional(Schema.String)
120
- })
120
+ }))
121
121
 
122
122
  /** @internal */
123
123
  const mapStatusCodeError = Effect.fnUntraced(function*(
@@ -137,16 +137,7 @@ const mapStatusCodeError = Effect.fnUntraced(function*(
137
137
 
138
138
  // Promote the provider's error message to the structured reason (else 5xx
139
139
  // shows "Server error" and 4xx shows a bare "HTTP <status>").
140
- let json: unknown = undefined
141
- // @effect-diagnostics effect/tryCatchInEffectGen:off
142
- if (Predicate.isNotUndefined(body)) {
143
- try {
144
- json = JSON.parse(body)
145
- } catch {
146
- json = undefined
147
- }
148
- }
149
- const decoded = Schema.decodeUnknownOption(BedrockErrorBody)(json)
140
+ const decoded = Schema.decodeUnknownOption(BedrockErrorBody)(body)
150
141
  const message = Option.isSome(decoded)
151
142
  ? decoded.value.message ?? decoded.value.Message
152
143
  : undefined
@@ -220,9 +211,23 @@ const buildInvalidRequestDescription = (params: {
220
211
  parts.push(`Response: ${truncated}`)
221
212
  }
222
213
 
214
+ if (params.message && dataRetentionRejection.test(params.message)) {
215
+ parts.push(dataRetentionHint)
216
+ }
217
+
223
218
  return parts.join(" ")
224
219
  }
225
220
 
221
+ // Converse has no request field for the data retention mode, so the AWS message
222
+ // alone leaves the reader looking for one. Only an account-level setting in each
223
+ // region, which the control plane sets, makes such a model callable.
224
+ const dataRetentionRejection = /data retention mode '[^']*' is not available for this model/
225
+
226
+ const dataRetentionHint = "The account's data retention mode in this region does not allow this model." +
227
+ " Claude Fable 5 and 5.1 need `aws_review`." +
228
+ " Set the mode with the Bedrock PutAccountDataRetention operation:" +
229
+ " https://docs.aws.amazon.com/bedrock/latest/userguide/data-retention.html"
230
+
226
231
  /**
227
232
  * AWS sends the exception shape as `Name:http://internal.amazon.com/coral/...`.
228
233
  * Only the part before the colon identifies it.
@@ -238,7 +243,7 @@ const extractErrorType = (header: string | undefined): string | undefined =>
238
243
  * expired `aws login` credentials are the common case. The exception shape in
239
244
  * `x-amzn-errortype` is what actually distinguishes them.
240
245
  */
241
- const authenticationKinds: Record<string, typeof AiError.AuthenticationError.Type["kind"]> = {
246
+ const authenticationKinds: Record<string, AiError.AuthenticationError["kind"]> = {
242
247
  AccessDeniedException: "InsufficientPermissions",
243
248
  ExpiredTokenException: "ExpiredKey",
244
249
  IncompleteSignatureException: "InvalidKey",
@@ -254,6 +259,17 @@ export const mapStatusCodeToReason = ({ errorType, http, message, status }: {
254
259
  readonly errorType?: string | undefined
255
260
  readonly http: typeof AiError.HttpContext.Type
256
261
  }): AiError.AiErrorReason => {
262
+ // AWS sends this with a 400, but the request is well formed. The account has
263
+ // used up a quota, which a short retry delay will not restore.
264
+ if (errorType === "ServiceQuotaExceededException") {
265
+ return new AiError.QuotaExhaustedError({ http })
266
+ }
267
+
268
+ // The exception shape is often the only clue to the cause, as when a 400
269
+ // arrives with the body `{}`.
270
+ const describe = (text: string | undefined): string | undefined =>
271
+ Predicate.isUndefined(errorType) ? text : Predicate.isUndefined(text) ? errorType : `${errorType}: ${text}`
272
+
257
273
  const invalidRequestDescription = buildInvalidRequestDescription({
258
274
  status,
259
275
  message,
@@ -267,7 +283,7 @@ export const mapStatusCodeToReason = ({ errorType, http, message, status }: {
267
283
  case 404:
268
284
  case 422:
269
285
  return new AiError.InvalidRequestError({
270
- description: invalidRequestDescription,
286
+ description: describe(invalidRequestDescription),
271
287
  http
272
288
  })
273
289
  case 401:
@@ -276,19 +292,19 @@ export const mapStatusCodeToReason = ({ errorType, http, message, status }: {
276
292
  kind: (Predicate.isNotUndefined(errorType) ? authenticationKinds[errorType] : undefined)
277
293
  // No header, or a shape we do not know: fall back to what the status implies.
278
294
  ?? (status === 401 ? "InvalidKey" : "InsufficientPermissions"),
279
- description: message,
295
+ description: describe(message),
280
296
  http
281
297
  })
282
298
  case 408:
283
299
  // ModelTimeoutException: the request took too long to process.
284
300
  return new AiError.InternalProviderError({
285
- description: message ?? "Model timeout",
301
+ description: describe(message) ?? "Model timeout",
286
302
  http
287
303
  })
288
304
  case 424:
289
305
  // ModelErrorException: the model returned an error response.
290
306
  return new AiError.InternalProviderError({
291
- description: message ?? "Model error",
307
+ description: describe(message) ?? "Model error",
292
308
  http
293
309
  })
294
310
  case 429:
@@ -298,12 +314,12 @@ export const mapStatusCodeToReason = ({ errorType, http, message, status }: {
298
314
  default:
299
315
  if (status >= 500) {
300
316
  return new AiError.InternalProviderError({
301
- description: message ?? "Server error",
317
+ description: describe(message) ?? "Server error",
302
318
  http
303
319
  })
304
320
  }
305
321
  return new AiError.UnknownError({
306
- description: message,
322
+ description: describe(message),
307
323
  http
308
324
  })
309
325
  }
@@ -0,0 +1,91 @@
1
+ import type * as Schema from "effect/Schema"
2
+ import type { FinishMetadata, GuardrailAction, GuardrailOutcome } from "../AmazonBedrockLanguageModel.ts"
3
+
4
+ const strength: Record<GuardrailAction, number> = { none: 0, masked: 1, blocked: 2 }
5
+
6
+ const stronger = (a: GuardrailAction, b: GuardrailAction): GuardrailAction => strength[b] > strength[a] ? b : a
7
+
8
+ const isObject = (value: unknown): value is Schema.MutableJsonObject =>
9
+ typeof value === "object" && value !== null && !Array.isArray(value)
10
+
11
+ const actionOf = (value: string): GuardrailAction =>
12
+ value === "BLOCKED" ? "blocked" : value === "ANONYMIZED" ? "masked" : "none"
13
+
14
+ const strongestAction = (value: Schema.MutableJson): GuardrailAction => {
15
+ let action: GuardrailAction = "none"
16
+ if (Array.isArray(value)) {
17
+ for (const item of value) {
18
+ action = stronger(action, strongestAction(item))
19
+ }
20
+ } else if (isObject(value)) {
21
+ for (const [key, member] of Object.entries(value)) {
22
+ action = stronger(
23
+ action,
24
+ key === "action" && typeof member === "string" ? actionOf(member) : strongestAction(member)
25
+ )
26
+ }
27
+ }
28
+ return action
29
+ }
30
+
31
+ const assessSide = (assessments: ReadonlyArray<Schema.MutableJson>, policies: Array<string>): GuardrailAction => {
32
+ let side: GuardrailAction = "none"
33
+ for (const assessment of assessments) {
34
+ if (!isObject(assessment)) {
35
+ continue
36
+ }
37
+ for (const [policy, member] of Object.entries(assessment)) {
38
+ const action = strongestAction(member)
39
+ if (action !== "none" && !policies.includes(policy)) {
40
+ policies.push(policy)
41
+ }
42
+ side = stronger(side, action)
43
+ }
44
+ }
45
+ return side
46
+ }
47
+
48
+ /** @internal */
49
+ export const guardrailOutcome = (trace: Schema.MutableJson | undefined): GuardrailOutcome | null => {
50
+ const guardrail = isObject(trace) ? trace.guardrail : undefined
51
+ if (!isObject(guardrail)) {
52
+ return null
53
+ }
54
+ const inputAssessment = guardrail.inputAssessment
55
+ const outputAssessments = guardrail.outputAssessments
56
+ const policies: Array<string> = []
57
+ const input = assessSide(isObject(inputAssessment) ? Object.values(inputAssessment) : [], policies)
58
+ const output = assessSide(
59
+ isObject(outputAssessments)
60
+ ? Object.values(outputAssessments).flatMap((chunks) => Array.isArray(chunks) ? chunks : [])
61
+ : [],
62
+ policies
63
+ )
64
+ return { input, output, policies }
65
+ }
66
+
67
+ /** @internal */
68
+ export const finishMetadata = (stopReason: string | null, trace: Schema.MutableJson | undefined): FinishMetadata => ({
69
+ stopReason,
70
+ guardrail: guardrailOutcome(trace),
71
+ trace: trace ?? null
72
+ })
73
+
74
+ const isJson = (text: string): boolean => {
75
+ try {
76
+ JSON.parse(text)
77
+ return true
78
+ } catch {
79
+ return false
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Bedrock stops with `guardrail_intervened` both when a guardrail blocks,
85
+ * replacing the text with the guardrail's blocked message, and when it masks,
86
+ * leaving the object JSON.
87
+ *
88
+ * @internal
89
+ */
90
+ export const blocksObject = (stopReason: string, text: string): boolean =>
91
+ stopReason === "guardrail_intervened" && !isJson(text)