@wmaurer/effect-ai-amazon-bedrock 0.2.0 → 0.4.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.
@@ -20,7 +20,11 @@
20
20
  * loudly with `AiError.InvalidUserInputError` rather than silently dropping
21
21
  * content. Prompt caching is opt-in per message or per part via provider
22
22
  * options. Citations are opt-in per document, and the citation blocks and
23
- * deltas Converse returns are surfaced as document source parts.
23
+ * deltas Converse returns are surfaced as document source parts. A guardrail
24
+ * applies through `Config.guardrailConfig`, and `guardContent` options on text
25
+ * parts, png and jpeg file parts, and system messages choose what it
26
+ * evaluates. Every finish part reports the raw stop reason and, when the trace
27
+ * is enabled, what the guardrail did.
24
28
  *
25
29
  * @stability unstable
26
30
  * @since 0.1.0
@@ -54,8 +58,13 @@ import type {
54
58
  ConverseRequest,
55
59
  ConverseResponse,
56
60
  ConverseResponseStreamEvent,
61
+ ConverseStreamRequest,
57
62
  DocumentBlock,
58
63
  DocumentSource,
64
+ GuardrailConverseContentBlock,
65
+ GuardrailConverseContentQualifier,
66
+ GuardrailConverseImageBlock,
67
+ GuardrailStreamConfiguration,
59
68
  ImageBlock,
60
69
  MediaSource,
61
70
  Message,
@@ -65,6 +74,7 @@ import type {
65
74
  ToolChoice,
66
75
  ToolConfiguration
67
76
  } from "./AmazonBedrockSchema.ts"
77
+ import * as InternalGuardrails from "./internal/guardrails.ts"
68
78
  import * as InternalUtilities from "./internal/utilities.ts"
69
79
 
70
80
  /**
@@ -149,15 +159,6 @@ interface CachePointOptions {
149
159
  }
150
160
 
151
161
  declare module "effect/ai/Prompt" {
152
- /**
153
- * Amazon Bedrock options for system messages.
154
- *
155
- * @stability unstable
156
- * @category models
157
- * @since 0.1.0
158
- */
159
- export interface SystemMessageOptions extends CachePointOptions {}
160
-
161
162
  /**
162
163
  * Amazon Bedrock options for user messages.
163
164
  *
@@ -185,15 +186,6 @@ declare module "effect/ai/Prompt" {
185
186
  */
186
187
  export interface ToolMessageOptions extends CachePointOptions {}
187
188
 
188
- /**
189
- * Amazon Bedrock options for text prompt parts.
190
- *
191
- * @stability unstable
192
- * @category models
193
- * @since 0.1.0
194
- */
195
- export interface TextPartOptions extends CachePointOptions {}
196
-
197
189
  /**
198
190
  * Amazon Bedrock options for tool call prompt parts.
199
191
  *
@@ -274,6 +266,13 @@ interface FileOptions {
274
266
  * Converse cannot cite.
275
267
  */
276
268
  readonly citations?: Citations | null
269
+ /**
270
+ * Sends the image as a `guardContent` image block, for the guardrail to
271
+ * evaluate. Only an `image/png` or `image/jpeg` part whose data is bytes
272
+ * or base64 can be guarded. Any other file part with this option fails
273
+ * the request with `InvalidUserInputError`.
274
+ */
275
+ readonly guardContent?: true | null
277
276
  } | null
278
277
  }
279
278
 
@@ -489,6 +488,190 @@ const reasoningTextInfo = (signature: string | undefined): ReasoningInfo => ({
489
488
  signature: signature ?? null
490
489
  })
491
490
 
491
+ /**
492
+ * A guardrail to apply to every request, set as `Config.guardrailConfig`.
493
+ *
494
+ * **Details**
495
+ *
496
+ * `generateText` and `generateObject` send it to Converse without
497
+ * `streamProcessingMode`, which Converse does not have. `streamText` sends it
498
+ * to ConverseStream as given.
499
+ *
500
+ * Without `guardContent` on any part, the guardrail evaluates every message
501
+ * and not the system prompt. Set `trace` to `"enabled"` to learn from the
502
+ * finish part whether the guardrail blocked or masked. The trace holds the
503
+ * matched text unmasked.
504
+ *
505
+ * @stability unstable
506
+ * @category models
507
+ * @since 0.4.0
508
+ */
509
+ export type GuardrailConfig = typeof GuardrailStreamConfiguration.Encoded
510
+
511
+ /**
512
+ * The role a guarded text part plays in a contextual grounding check.
513
+ *
514
+ * @stability unstable
515
+ * @category models
516
+ * @since 0.4.0
517
+ */
518
+ export type GuardContentQualifier = typeof GuardrailConverseContentQualifier.Type
519
+
520
+ /**
521
+ * Marks a text part or a system message for the guardrail to evaluate.
522
+ *
523
+ * **Details**
524
+ *
525
+ * `true` guards the text. `qualifiers` names its role in a contextual
526
+ * grounding check: the source documents are `grounding_source`, and the
527
+ * question is `query`. Once any part of a request is guarded, the guardrail
528
+ * evaluates only the guarded parts. The model still reads every part.
529
+ *
530
+ * The option stays on the part, so in a chat a guarded turn is evaluated
531
+ * again on every later turn.
532
+ *
533
+ * @stability unstable
534
+ * @category models
535
+ * @since 0.4.0
536
+ */
537
+ export type GuardContent = true | {
538
+ readonly qualifiers: ReadonlyArray<GuardContentQualifier>
539
+ }
540
+
541
+ interface GuardableOptions {
542
+ readonly amazonBedrock?: {
543
+ /**
544
+ * Marks the end of the reusable prefix of the request. The cache point is
545
+ * emitted after the content this is attached to.
546
+ */
547
+ readonly cachePoint?: CachePoint | null
548
+ /**
549
+ * Sends the text as a `guardContent` block, for the guardrail to evaluate.
550
+ * Honoured on user text parts and system messages. On an assistant text
551
+ * part it fails the request with `InvalidUserInputError`.
552
+ */
553
+ readonly guardContent?: GuardContent | null
554
+ } | null
555
+ }
556
+
557
+ declare module "effect/ai/Prompt" {
558
+ /**
559
+ * Amazon Bedrock options for system messages.
560
+ *
561
+ * @stability unstable
562
+ * @category models
563
+ * @since 0.1.0
564
+ */
565
+ export interface SystemMessageOptions extends GuardableOptions {}
566
+
567
+ /**
568
+ * Amazon Bedrock options for text prompt parts.
569
+ *
570
+ * @stability unstable
571
+ * @category models
572
+ * @since 0.1.0
573
+ */
574
+ export interface TextPartOptions extends GuardableOptions {}
575
+ }
576
+
577
+ const textBlock = (
578
+ text: string,
579
+ options: GuardableOptions
580
+ ): { readonly text: string } | { readonly guardContent: typeof GuardrailConverseContentBlock.Encoded } => {
581
+ const guard = options.amazonBedrock?.guardContent
582
+ if (Predicate.isNullish(guard)) {
583
+ return { text }
584
+ }
585
+ return { guardContent: { text: guard === true ? { text } : { text, qualifiers: guard.qualifiers } } }
586
+ }
587
+
588
+ /**
589
+ * What a guardrail did to one side of the exchange.
590
+ *
591
+ * @stability unstable
592
+ * @category models
593
+ * @since 0.4.0
594
+ */
595
+ export type GuardrailAction = "blocked" | "masked" | "none"
596
+
597
+ /**
598
+ * What the guardrails did, read from the trace.
599
+ *
600
+ * **Details**
601
+ *
602
+ * `input` and `output` are the strongest action that any guardrail took on
603
+ * that side, `blocked` over `masked` over `none`, across every guardrail in
604
+ * the trace, enforced guardrails included. `policies` lists the trace keys of
605
+ * the policies that blocked or masked, such as `wordPolicy` or
606
+ * `sensitiveInformationPolicy`, each once, in the order the trace names them.
607
+ *
608
+ * @stability unstable
609
+ * @category models
610
+ * @since 0.4.0
611
+ */
612
+ export type GuardrailOutcome = {
613
+ readonly input: GuardrailAction
614
+ readonly output: GuardrailAction
615
+ // Mutable, because the same type rides on ContentPolicyError, whose
616
+ // metadata must be MutableJson.
617
+ readonly policies: Array<string>
618
+ }
619
+
620
+ /**
621
+ * The Amazon Bedrock metadata on every finish part.
622
+ *
623
+ * **Details**
624
+ *
625
+ * `stopReason` is the raw Converse stop reason. It tells
626
+ * `guardrail_intervened` from `content_filtered`, which both finish with
627
+ * `"content-filter"`, and `max_tokens` from `model_context_window_exceeded`,
628
+ * which both finish with `"length"`. It is `null` only when a stream ended
629
+ * without a `messageStop` event.
630
+ *
631
+ * `guardrail` is `null` when the response carried no guardrail trace, which
632
+ * is the case unless `trace` is enabled in the guardrail config. A trace in
633
+ * which nothing acted gives `{ input: "none", output: "none", policies: [] }`.
634
+ * `trace` is the Converse trace as Bedrock sent it, or `null`. It holds the
635
+ * text that a policy matched, unmasked.
636
+ *
637
+ * @stability unstable
638
+ * @category models
639
+ * @since 0.4.0
640
+ */
641
+ export type FinishMetadata = {
642
+ readonly stopReason: string | null
643
+ readonly guardrail: GuardrailOutcome | null
644
+ readonly trace: Schema.MutableJson | null
645
+ }
646
+
647
+ declare module "effect/ai/AiError" {
648
+ /**
649
+ * Amazon Bedrock metadata on the `ContentPolicyError` that `generateObject`
650
+ * fails with when a guardrail blocked the exchange. It is the metadata of
651
+ * the response's finish part.
652
+ *
653
+ * @stability unstable
654
+ * @category models
655
+ * @since 0.4.0
656
+ */
657
+ export interface ContentPolicyErrorMetadata extends ProviderMetadata {
658
+ readonly amazonBedrock?: FinishMetadata | null
659
+ }
660
+ }
661
+
662
+ declare module "effect/ai/Response" {
663
+ /**
664
+ * Amazon Bedrock metadata on the finish part.
665
+ *
666
+ * @stability unstable
667
+ * @category models
668
+ * @since 0.4.0
669
+ */
670
+ export interface FinishPartMetadata extends ProviderMetadata {
671
+ readonly amazonBedrock?: FinishMetadata | null
672
+ }
673
+ }
674
+
492
675
  // =============================================================================
493
676
  // Configuration
494
677
  // =============================================================================
@@ -498,12 +681,17 @@ const reasoningTextInfo = (signature: string | undefined): ReasoningInfo => ({
498
681
  *
499
682
  * **Details**
500
683
  *
501
- * Derived from `ConverseRequest`, minus the fields built from the prompt, so
502
- * every Converse request parameter is settable per request. That includes
503
- * `additionalModelRequestFields`, which carries inference parameters specific
504
- * to the target model — among them extended thinking on Anthropic models. The
505
- * thinking budget is drawn from the same ceiling as the visible answer, so
506
- * `inferenceConfig.maxTokens` must exceed `budget_tokens`.
684
+ * Derived from `ConverseStreamRequest`, minus the fields built from the
685
+ * prompt, so every Converse request parameter is settable per request. That
686
+ * includes `additionalModelRequestFields`, which carries inference parameters
687
+ * specific to the target model — among them extended thinking on Anthropic
688
+ * models. The thinking budget is drawn from the same ceiling as the visible
689
+ * answer, so `inferenceConfig.maxTokens` must exceed `budget_tokens`.
690
+ *
691
+ * It also includes `guardrailConfig` (see {@link GuardrailConfig}). An
692
+ * override replaces the whole object, and `guardrailConfig: undefined` turns
693
+ * off a layer's guardrail for one request. `generateText` does not send
694
+ * `streamProcessingMode`.
507
695
  *
508
696
  * **Example** (Enabling extended thinking for one request)
509
697
  *
@@ -532,7 +720,7 @@ export class Config extends Context.Service<
532
720
  Simplify<
533
721
  & Partial<
534
722
  Omit<
535
- typeof ConverseRequest.Encoded,
723
+ typeof ConverseStreamRequest.Encoded,
536
724
  "messages" | "system" | "outputConfig"
537
725
  >
538
726
  >
@@ -554,11 +742,15 @@ export class Config extends Context.Service<
554
742
  *
555
743
  * **Details**
556
744
  *
557
- * Overrides automatic capability detection based on the model
558
- * identifier. When `false`, `generateObject` asks for the object through
559
- * a tool instead, which is forced on models that accept a forced
560
- * `toolChoice` and left unforced, with an instruction to call it, on
561
- * models that do not. Never sent to Bedrock.
745
+ * Overrides the default for the provider that the model identifier
746
+ * names. By default only Claude models, and ARNs that do not name their
747
+ * model, get native structured output. In probes on 2026-10-17 in
748
+ * eu-west-1 and us-east-1, Nova, Mistral Large 2402, Pixtral Large,
749
+ * Llama, and Palmyra answered `outputConfig` with a 400. When `false`,
750
+ * `generateObject` asks for the object through a tool instead, which is
751
+ * forced on models that accept a forced `toolChoice` and left unforced,
752
+ * with an instruction to call it, on models that do not. Never sent to
753
+ * Bedrock.
562
754
  *
563
755
  * When the prompt holds tool calls or tool results and the request
564
756
  * offers no tools, `generateObject` uses the tool even when this is
@@ -571,12 +763,19 @@ export class Config extends Context.Service<
571
763
  *
572
764
  * **Details**
573
765
  *
574
- * Overrides automatic capability detection based on the model
575
- * identifier. It decides whether the tool that `generateObject` uses
576
- * without native structured output is forced, so with
577
- * `structuredOutputs: false` it selects between the forced and the
578
- * unforced tool, for example for an application inference profile ARN,
579
- * which does not name its model. Never sent to Bedrock.
766
+ * Overrides the default for the provider that the model identifier
767
+ * names. It decides whether the tool that `generateObject` uses
768
+ * without native structured output is forced with `toolChoice`
769
+ * `{ any: {} }`, so with `structuredOutputs: false` it selects between
770
+ * the forced and the unforced tool, for example for an application
771
+ * inference profile ARN, which does not name its model. Never sent to
772
+ * Bedrock.
773
+ *
774
+ * By default Meta and Writer models, and Claude Opus 5.5, Sonnet 5.5,
775
+ * Fable 5.1, and Mythos 5.1, get the unforced tool. In probes on
776
+ * 2026-10-17 in us-east-1, Llama 3.1, 3.3, and 4 and Palmyra X4 and X5
777
+ * answered `any` with a 400, and in eu-west-1 Opus 5.5 and Sonnet 5.5
778
+ * rejected both `any` and a named `tool`.
580
779
  *
581
780
  * It steers only that tool. A `toolChoice` passed with the caller's own
582
781
  * tools, such as `"required"` or a named tool, goes to Bedrock
@@ -624,7 +823,7 @@ export const make = Effect.fnUntraced(function*({ config: providerConfig, model
624
823
  function*(
625
824
  options: LanguageModel.ProviderOptions
626
825
  ): Effect.fn.Return<{
627
- readonly request: typeof ConverseRequest.Encoded
826
+ readonly request: typeof ConverseStreamRequest.Encoded
628
827
  readonly nameMapper: Tool.NameMapper<ReadonlyArray<Tool.Any>>
629
828
  readonly documents: ReadonlyArray<CitedDocument>
630
829
  }, AiError.AiError> {
@@ -674,9 +873,9 @@ export const make = Effect.fnUntraced(function*({ config: providerConfig, model
674
873
  }
675
874
  }
676
875
  } else {
677
- // The object is asked for through a tool, which replaces the
678
- // caller's tools. A model that rejects a forced `toolChoice` gets
679
- // the tool unforced, and the request says to call it.
876
+ // The object tool is the only tool, so `any` forces it, and
877
+ // Mistral Large 2402 and Pixtral Large accept `any` but reject a
878
+ // named `tool`.
680
879
  const forced = forcedToolChoice ?? capabilities.supportsForcedToolChoice
681
880
  nameMapper = new Tool.NameMapper([])
682
881
  toolConfig = {
@@ -687,7 +886,7 @@ export const make = Effect.fnUntraced(function*({ config: providerConfig, model
687
886
  inputSchema: { json: json as Record<string, unknown> }
688
887
  }
689
888
  }],
690
- toolChoice: forced ? { tool: { name: responseFormat.objectName } } : { auto: {} }
889
+ toolChoice: forced ? { any: {} } : { auto: {} }
691
890
  }
692
891
  if (!forced) {
693
892
  system = [...(system ?? []), {
@@ -697,7 +896,7 @@ export const make = Effect.fnUntraced(function*({ config: providerConfig, model
697
896
  }
698
897
  }
699
898
 
700
- const request: typeof ConverseRequest.Encoded = {
899
+ const request: typeof ConverseStreamRequest.Encoded = {
701
900
  ...config,
702
901
  modelId,
703
902
  system,
@@ -725,7 +924,8 @@ export const make = Effect.fnUntraced(function*({ config: providerConfig, model
725
924
  return yield* LanguageModel.make({
726
925
  codecTransformer: toCodecAnthropic,
727
926
  generateText: Effect.fnUntraced(function*(options) {
728
- const { documents, nameMapper, request } = yield* makeRequest(options)
927
+ const { documents, nameMapper, request: streamRequest } = yield* makeRequest(options)
928
+ const request = toConverseRequest(streamRequest)
729
929
  const rawResponse = yield* client.converse({ payload: request })
730
930
  return yield* makeResponse(request, rawResponse, options, nameMapper, documents)
731
931
  }),
@@ -737,6 +937,17 @@ export const make = Effect.fnUntraced(function*({ config: providerConfig, model
737
937
  })
738
938
  })
739
939
 
940
+ const toConverseRequest = (
941
+ request: typeof ConverseStreamRequest.Encoded
942
+ ): typeof ConverseRequest.Encoded => {
943
+ const { guardrailConfig, ...rest } = request
944
+ if (Predicate.isUndefined(guardrailConfig)) {
945
+ return rest
946
+ }
947
+ const { streamProcessingMode: _, ...converseGuardrail } = guardrailConfig
948
+ return { ...rest, guardrailConfig: converseGuardrail }
949
+ }
950
+
740
951
  /**
741
952
  * Creates a layer for the Amazon Bedrock language model.
742
953
  *
@@ -891,6 +1102,45 @@ const documentName = (fileName: string | undefined, position: number): string =>
891
1102
  return `document ${position}`
892
1103
  }
893
1104
 
1105
+ const guardedImageFormats: Record<string, typeof GuardrailConverseImageBlock.Encoded["format"]> = {
1106
+ "image/jpeg": "jpeg",
1107
+ "image/png": "png"
1108
+ }
1109
+
1110
+ const guardedImage: (
1111
+ part: Prompt.FilePart
1112
+ ) => Effect.Effect<typeof GuardrailConverseImageBlock.Encoded, AiError.AiError> = Effect.fnUntraced(
1113
+ function*(part) {
1114
+ const format = guardedImageFormats[part.mediaType]
1115
+ if (Predicate.isUndefined(format)) {
1116
+ const reason = part.mediaType === "image/*"
1117
+ ? "its format is unknown, and a guarded image must be image/png or image/jpeg"
1118
+ : part.mediaType.startsWith("image/")
1119
+ ? "a guarded image must be image/png or image/jpeg"
1120
+ : "only text parts and png or jpeg images can be guarded"
1121
+ return yield* AiError.make({
1122
+ module: "AmazonBedrockLanguageModel",
1123
+ method: "prepareMessages",
1124
+ reason: new AiError.InvalidUserInputError({
1125
+ description: `Unsupported guardContent on a file part of media type '${part.mediaType}' - ${reason}`
1126
+ })
1127
+ })
1128
+ }
1129
+ const source = yield* fileSource(part.data)
1130
+ if (Predicate.isUndefined(source.bytes)) {
1131
+ return yield* AiError.make({
1132
+ module: "AmazonBedrockLanguageModel",
1133
+ method: "prepareMessages",
1134
+ reason: new AiError.InvalidUserInputError({
1135
+ description: `Unsupported guardContent on an image at '${source.s3Location?.uri}' - a guarded image ` +
1136
+ "must be sent as bytes or base64, not as an s3:// location"
1137
+ })
1138
+ })
1139
+ }
1140
+ return { format, source: { bytes: source.bytes } }
1141
+ }
1142
+ )
1143
+
894
1144
  const prepareMessages: (options: LanguageModel.ProviderOptions) => Effect.Effect<{
895
1145
  readonly system: ReadonlyArray<typeof SystemContentBlock.Encoded>
896
1146
  readonly messages: ReadonlyArray<typeof Message.Encoded>
@@ -922,7 +1172,7 @@ const prepareMessages: (options: LanguageModel.ProviderOptions) => Effect.Effect
922
1172
  })
923
1173
  }
924
1174
  for (const message of group.messages) {
925
- system.push({ text: message.content })
1175
+ system.push(textBlock(message.content, message.options))
926
1176
  const cachePoint = getCachePoint(message)
927
1177
  if (Predicate.isNotNull(cachePoint)) {
928
1178
  system.push({ cachePoint })
@@ -939,9 +1189,14 @@ const prepareMessages: (options: LanguageModel.ProviderOptions) => Effect.Effect
939
1189
  case "user": {
940
1190
  for (const part of message.content) {
941
1191
  if (part.type === "text") {
942
- content.push({ text: part.text })
1192
+ content.push(textBlock(part.text, part.options))
943
1193
  pushCachePoint(content, part)
944
1194
  } else if (part.type === "file") {
1195
+ if (part.options.amazonBedrock?.guardContent === true) {
1196
+ content.push({ guardContent: { image: yield* guardedImage(part) } })
1197
+ pushCachePoint(content, part)
1198
+ continue
1199
+ }
945
1200
  const imageFormat = imageFormats[part.mediaType]
946
1201
  if (Predicate.isNotUndefined(imageFormat)) {
947
1202
  content.push({ image: { format: imageFormat, source: yield* fileSource(part.data) } })
@@ -1037,6 +1292,16 @@ const prepareMessages: (options: LanguageModel.ProviderOptions) => Effect.Effect
1037
1292
  const isLastPart = k === message.content.length - 1
1038
1293
 
1039
1294
  if (part.type === "text") {
1295
+ if (!Predicate.isNullish(part.options.amazonBedrock?.guardContent)) {
1296
+ return yield* AiError.make({
1297
+ module: "AmazonBedrockLanguageModel",
1298
+ method: "prepareMessages",
1299
+ reason: new AiError.InvalidUserInputError({
1300
+ description: "Unsupported guardContent on an assistant text part - this provider guards only " +
1301
+ "user text parts, user png and jpeg images, and system messages"
1302
+ })
1303
+ })
1304
+ }
1040
1305
  // Skip empty text blocks
1041
1306
  if (part.text.trim().length === 0) {
1042
1307
  continue
@@ -1124,16 +1389,36 @@ interface ModelCapabilities {
1124
1389
  }
1125
1390
 
1126
1391
  /**
1127
- * Returns what a model accepts for structured output, optimistically assuming
1128
- * modern capabilities for unknown IDs. Matched by substring, so foundation
1129
- * model ids and inference profile ids (`eu.anthropic.…`) both resolve.
1392
+ * Returns what a model accepts for structured output, looked up by the
1393
+ * provider that the model ID names: `anthropic` in
1394
+ * `eu.anthropic.claude-opus-4-8`, `amazon` in `amazon.nova-pro-v1:0`. A
1395
+ * foundation model or inference profile ARN names its model at the end. Other
1396
+ * ARNs, such as an application inference profile or a provisioned model, do
1397
+ * not. They get the Claude rules, which give them the native structured output
1398
+ * and forced tool of 0.2.0, on the unmeasured assumption that most of them
1399
+ * wrap a Claude model.
1400
+ *
1401
+ * Probes on 2026-10-17, one run per model and call unless noted:
1130
1402
  *
1403
+ * - Only Claude gets native structured output. Nova, Mistral Large 2402 and
1404
+ * Pixtral Large in eu-west-1, and Llama, Palmyra, DeepSeek R1, Mistral 7B
1405
+ * and Mixtral in us-east-1, answer `outputConfig` with a 400. gpt-oss-120b
1406
+ * accepts it but wrote text before the JSON in 3 of 4 runs.
1407
+ * - The forced object tool, sent as `{ any: {} }`, returned the object on 15
1408
+ * of 16 models in eu-west-1, Claude Haiku 4.5 and Opus 4.8 included, and
1409
+ * on all 17 models in us-east-1 that accept a forced tool. Magistral Small
1410
+ * fails every object path.
1411
+ * - Llama 3.1, 3.3 and 4 and Palmyra X4 and X5 (`meta`, `writer`) answer
1412
+ * `any` with a 400 in us-east-1, and so does each of them that the probe
1413
+ * sent a named `tool`, all but Llama 3.1 8B. They get the unforced tool. It
1414
+ * returned the object on Llama 3.1 8B, Llama 4 Scout and Maverick and
1415
+ * Palmyra X5, and a wrong object on Llama 3.1 70B and 3.3 70B.
1131
1416
  * - Claude models before Opus 4.1 / Sonnet 4.5 predate structured output, as
1132
1417
  * in the Anthropic provider. This is not checked on Bedrock: a probe of 17
1133
- * Claude inference profiles in eu-west-1 on 2026-10-17 found Claude 3 Haiku,
1134
- * Claude 3 Sonnet and Sonnet 4 at end of life there, and did not try Claude
1135
- * 3 Opus, 3.5 Haiku, 3.5 Sonnet, 3.7 Sonnet, Opus 4 or Opus 4.1.
1136
- * - Every Claude profile that served the probe's requests (12 of 17)
1418
+ * Claude inference profiles in eu-west-1 found Claude 3 Haiku, Claude 3
1419
+ * Sonnet and Sonnet 4 at end of life there, and did not try Claude 3 Opus,
1420
+ * 3.5 Haiku, 3.5 Sonnet, 3.7 Sonnet, Opus 4 or Opus 4.1.
1421
+ * - Every Claude profile that served that probe's requests (12 of 17)
1137
1422
  * accepted `outputConfig.textFormat`: Haiku 4.5 and 5.5, Opus 4.5 to 4.8, 5
1138
1423
  * and 5.5, and Sonnet 4.5, 4.6, 5 and 5.5. Fable 5 and 5.1 rejected every
1139
1424
  * request, with or without `textFormat`, with a data-retention
@@ -1146,23 +1431,51 @@ interface ModelCapabilities {
1146
1431
  * @see https://platform.claude.com/docs/en/build-with-claude/structured-outputs
1147
1432
  */
1148
1433
  const getModelCapabilities = (modelId: string): ModelCapabilities => {
1149
- const supportsForcedToolChoice = !(
1150
- modelId.includes("claude-opus-5-5") ||
1151
- modelId.includes("claude-sonnet-5-5") ||
1152
- modelId.includes("claude-fable-5-1") ||
1153
- modelId.includes("claude-mythos-5-1")
1154
- )
1155
- const supportsStructuredOutput = !(
1156
- modelId.includes("claude-sonnet-4-0") ||
1157
- modelId.includes("claude-sonnet-4-20250514") ||
1158
- modelId.includes("claude-3-7-sonnet") ||
1159
- modelId.includes("claude-opus-4-0") ||
1160
- modelId.includes("claude-opus-4-20250514") ||
1161
- modelId.includes("claude-3-")
1162
- )
1163
- return { supportsStructuredOutput, supportsForcedToolChoice }
1434
+ const named = modelId.startsWith("arn:") ? namingArn.exec(modelId)?.[1] : modelId
1435
+ if (Predicate.isUndefined(named)) {
1436
+ return claudeCapabilities(modelId)
1437
+ }
1438
+ // `provider.model`, or `geo.provider.model` for an inference profile.
1439
+ const [first = "", second = ""] = named.split(".")
1440
+ const byProvider = capabilitiesByProvider.get(first) ?? capabilitiesByProvider.get(second)
1441
+ return Predicate.isUndefined(byProvider) ? otherProvider : byProvider(named)
1164
1442
  }
1165
1443
 
1444
+ // Of the ARNs Converse accepts, only these two end in the ID of the model
1445
+ // they invoke.
1446
+ const namingArn = /^arn:[^:]+:bedrock:[^:]*:[^:]*:(?:foundation-model|inference-profile)\/(.+)$/
1447
+
1448
+ const otherProvider: ModelCapabilities = { supportsStructuredOutput: false, supportsForcedToolChoice: true }
1449
+
1450
+ const unforcedTool: ModelCapabilities = { supportsStructuredOutput: false, supportsForcedToolChoice: false }
1451
+
1452
+ const claudeWithoutStructuredOutput = [
1453
+ "claude-sonnet-4-0",
1454
+ "claude-sonnet-4-20250514",
1455
+ "claude-3-7-sonnet",
1456
+ "claude-opus-4-0",
1457
+ "claude-opus-4-20250514",
1458
+ "claude-3-"
1459
+ ]
1460
+
1461
+ const claudeWithoutForcedToolChoice = [
1462
+ "claude-opus-5-5",
1463
+ "claude-sonnet-5-5",
1464
+ "claude-fable-5-1",
1465
+ "claude-mythos-5-1"
1466
+ ]
1467
+
1468
+ const claudeCapabilities = (modelId: string): ModelCapabilities => ({
1469
+ supportsStructuredOutput: !claudeWithoutStructuredOutput.some((id) => modelId.includes(id)),
1470
+ supportsForcedToolChoice: !claudeWithoutForcedToolChoice.some((id) => modelId.includes(id))
1471
+ })
1472
+
1473
+ const capabilitiesByProvider = new Map<string, (modelId: string) => ModelCapabilities>([
1474
+ ["anthropic", claudeCapabilities],
1475
+ ["meta", () => unforcedTool],
1476
+ ["writer", () => unforcedTool]
1477
+ ])
1478
+
1166
1479
  const tryJsonSchema = (schema: Schema.Top, method: string) =>
1167
1480
  Effect.try({
1168
1481
  try: () => Tool.getJsonSchemaFromSchema(schema, { transformer: toCodecAnthropic }),
@@ -1267,7 +1580,7 @@ const makeResponse: (
1267
1580
  documents: ReadonlyArray<CitedDocument>
1268
1581
  ) => Effect.Effect<
1269
1582
  Array<Response.PartEncoded>,
1270
- never,
1583
+ AiError.AiError,
1271
1584
  IdGenerator.IdGenerator
1272
1585
  > = Effect.fnUntraced(function*(request, response, options, nameMapper, documents) {
1273
1586
  const idGenerator = yield* IdGenerator.IdGenerator
@@ -1375,6 +1688,22 @@ const makeResponse: (
1375
1688
  : InternalUtilities.resolveFinishReason(response.stopReason)
1376
1689
  const cacheReadTokens = response.usage.cacheReadInputTokens ?? 0
1377
1690
  const cacheWriteTokens = response.usage.cacheWriteInputTokens ?? 0
1691
+ const metadata = InternalGuardrails.finishMetadata(response.stopReason, response.trace)
1692
+
1693
+ if (options.responseFormat.type === "json") {
1694
+ const text = parts.flatMap((part) => part.type === "text" ? [part.text] : []).join("")
1695
+ if (InternalGuardrails.blocksObject(response.stopReason, text)) {
1696
+ return yield* AiError.make({
1697
+ module: "AmazonBedrockLanguageModel",
1698
+ method: "generateObject",
1699
+ reason: new AiError.ContentPolicyError({
1700
+ description: "An Amazon Bedrock guardrail intervened, and the response holds no JSON object. " +
1701
+ `Bedrock answered: ${truncate(text, 200)}`,
1702
+ metadata: { amazonBedrock: metadata }
1703
+ })
1704
+ })
1705
+ }
1706
+ }
1378
1707
 
1379
1708
  parts.push({
1380
1709
  type: "finish",
@@ -1394,14 +1723,15 @@ const makeResponse: (
1394
1723
  },
1395
1724
  // `Response.FinishPart` models `response` with `Schema.UndefinedOr`, which
1396
1725
  // requires the key; HTTP response details are not surfaced by the client.
1397
- response: undefined
1726
+ response: undefined,
1727
+ metadata: { amazonBedrock: metadata }
1398
1728
  })
1399
1729
 
1400
1730
  return parts
1401
1731
  })
1402
1732
 
1403
1733
  const makeStreamResponse: (
1404
- request: typeof ConverseRequest.Encoded,
1734
+ request: typeof ConverseStreamRequest.Encoded,
1405
1735
  stream: Stream.Stream<ConverseResponseStreamEvent, AiError.AiError>,
1406
1736
  nameMapper: Tool.NameMapper<ReadonlyArray<Tool.Any>>,
1407
1737
  documents: ReadonlyArray<CitedDocument>
@@ -1444,6 +1774,7 @@ const makeStreamResponse: (
1444
1774
  // (which AWS sends AFTER messageStop and which carries the usage), so finish
1445
1775
  // reports the real token counts rather than the pre-metadata zeros.
1446
1776
  let finishReason: Response.FinishReason = "unknown"
1777
+ let stopReason: string | null = null
1447
1778
 
1448
1779
  return stream.pipe(
1449
1780
  Stream.mapEffect(Effect.fnUntraced(function*(event) {
@@ -1591,6 +1922,19 @@ const makeStreamResponse: (
1591
1922
  const block = toolBlocks.get(index)
1592
1923
  if (Predicate.isNotUndefined(block)) {
1593
1924
  toolBlocks.delete(index)
1925
+ const params = yield* Effect.try({
1926
+ // A tool taking no arguments streams no deltas at all.
1927
+ try: () => Tool.unsafeSecureJsonParse(block.params.length === 0 ? "{}" : block.params),
1928
+ catch: () =>
1929
+ AiError.make({
1930
+ module: "AmazonBedrockLanguageModel",
1931
+ method: "makeStreamResponse",
1932
+ reason: new AiError.InvalidOutputError({
1933
+ description: `Bedrock streamed input for the \`${block.name}\` tool that is not valid JSON: ` +
1934
+ truncate(block.params, 500)
1935
+ })
1936
+ })
1937
+ })
1594
1938
  parts.push({
1595
1939
  type: "tool-params-end",
1596
1940
  id: block.id
@@ -1599,8 +1943,7 @@ const makeStreamResponse: (
1599
1943
  type: "tool-call",
1600
1944
  id: block.id,
1601
1945
  name: block.name,
1602
- // A tool taking no arguments streams no deltas at all.
1603
- params: Tool.unsafeSecureJsonParse(block.params.length === 0 ? "{}" : block.params),
1946
+ params,
1604
1947
  providerExecuted: false
1605
1948
  })
1606
1949
  }
@@ -1611,6 +1954,7 @@ const makeStreamResponse: (
1611
1954
  // Defer the `finish` part to the trailing `metadata` event (usage
1612
1955
  // is still zero here — metadata has not arrived yet).
1613
1956
  finishReason = InternalUtilities.resolveFinishReason(event.messageStop.stopReason)
1957
+ stopReason = event.messageStop.stopReason
1614
1958
  break
1615
1959
  }
1616
1960
 
@@ -1639,7 +1983,8 @@ const makeStreamResponse: (
1639
1983
  }
1640
1984
  },
1641
1985
  // See `makeResponse`: the key is required (`Schema.UndefinedOr`).
1642
- response: undefined
1986
+ response: undefined,
1987
+ metadata: { amazonBedrock: InternalGuardrails.finishMetadata(stopReason, event.metadata.trace) }
1643
1988
  })
1644
1989
  break
1645
1990
  }
@@ -1744,3 +2089,6 @@ const trimIfLast = (
1744
2089
  isLastPart: boolean,
1745
2090
  text: string
1746
2091
  ) => isLastGroup && isLastMessage && isLastPart ? text.trim() : text
2092
+
2093
+ const truncate = (text: string, maxLength: number): string =>
2094
+ text.length > maxLength ? `${text.slice(0, maxLength)}...` : text