@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.
@@ -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
@@ -32,6 +36,7 @@ import * as LanguageModel from "effect/ai/LanguageModel"
32
36
  import * as AiModel from "effect/ai/Model"
33
37
  import type * as Prompt from "effect/ai/Prompt"
34
38
  import type * as Response from "effect/ai/Response"
39
+ import { addGenAIAnnotations } from "effect/ai/Telemetry"
35
40
  import * as Tool from "effect/ai/Tool"
36
41
  import * as Context from "effect/Context"
37
42
  import * as DateTime from "effect/DateTime"
@@ -41,9 +46,10 @@ import { dual } from "effect/Function"
41
46
  import * as Layer from "effect/Layer"
42
47
  import * as Predicate from "effect/Predicate"
43
48
  import * as Result from "effect/Result"
44
- import type * as Schema from "effect/Schema"
49
+ import * as Schema from "effect/Schema"
45
50
  import * as SchemaAST from "effect/SchemaAST"
46
51
  import * as Stream from "effect/Stream"
52
+ import type { Span } from "effect/Tracer"
47
53
  import type { Mutable, Simplify } from "effect/Types"
48
54
  import { AmazonBedrockClient } from "./AmazonBedrockClient.ts"
49
55
  import type {
@@ -54,17 +60,24 @@ import type {
54
60
  ConverseRequest,
55
61
  ConverseResponse,
56
62
  ConverseResponseStreamEvent,
63
+ ConverseStreamRequest,
57
64
  DocumentBlock,
58
65
  DocumentSource,
66
+ GuardrailConverseContentBlock,
67
+ GuardrailConverseContentQualifier,
68
+ GuardrailConverseImageBlock,
69
+ GuardrailStreamConfiguration,
59
70
  ImageBlock,
60
71
  MediaSource,
61
72
  Message,
62
73
  OutputConfig,
63
74
  SystemContentBlock,
75
+ TokenUsage,
64
76
  Tool as BedrockTool,
65
77
  ToolChoice,
66
78
  ToolConfiguration
67
79
  } from "./AmazonBedrockSchema.ts"
80
+ import * as InternalGuardrails from "./internal/guardrails.ts"
68
81
  import * as InternalUtilities from "./internal/utilities.ts"
69
82
 
70
83
  /**
@@ -149,15 +162,6 @@ interface CachePointOptions {
149
162
  }
150
163
 
151
164
  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
165
  /**
162
166
  * Amazon Bedrock options for user messages.
163
167
  *
@@ -185,15 +189,6 @@ declare module "effect/ai/Prompt" {
185
189
  */
186
190
  export interface ToolMessageOptions extends CachePointOptions {}
187
191
 
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
192
  /**
198
193
  * Amazon Bedrock options for tool call prompt parts.
199
194
  *
@@ -274,6 +269,13 @@ interface FileOptions {
274
269
  * Converse cannot cite.
275
270
  */
276
271
  readonly citations?: Citations | null
272
+ /**
273
+ * Sends the image as a `guardContent` image block, for the guardrail to
274
+ * evaluate. Only an `image/png` or `image/jpeg` part whose data is bytes
275
+ * or base64 can be guarded. Any other file part with this option fails
276
+ * the request with `InvalidUserInputError`.
277
+ */
278
+ readonly guardContent?: true | null
277
279
  } | null
278
280
  }
279
281
 
@@ -489,6 +491,190 @@ const reasoningTextInfo = (signature: string | undefined): ReasoningInfo => ({
489
491
  signature: signature ?? null
490
492
  })
491
493
 
494
+ /**
495
+ * A guardrail to apply to every request, set as `Config.guardrailConfig`.
496
+ *
497
+ * **Details**
498
+ *
499
+ * `generateText` and `generateObject` send it to Converse without
500
+ * `streamProcessingMode`, which Converse does not have. `streamText` sends it
501
+ * to ConverseStream as given.
502
+ *
503
+ * Without `guardContent` on any part, the guardrail evaluates every message
504
+ * and not the system prompt. Set `trace` to `"enabled"` to learn from the
505
+ * finish part whether the guardrail blocked or masked. The trace holds the
506
+ * matched text unmasked.
507
+ *
508
+ * @stability unstable
509
+ * @category models
510
+ * @since 0.4.0
511
+ */
512
+ export type GuardrailConfig = typeof GuardrailStreamConfiguration.Encoded
513
+
514
+ /**
515
+ * The role a guarded text part plays in a contextual grounding check.
516
+ *
517
+ * @stability unstable
518
+ * @category models
519
+ * @since 0.4.0
520
+ */
521
+ export type GuardContentQualifier = typeof GuardrailConverseContentQualifier.Type
522
+
523
+ /**
524
+ * Marks a text part or a system message for the guardrail to evaluate.
525
+ *
526
+ * **Details**
527
+ *
528
+ * `true` guards the text. `qualifiers` names its role in a contextual
529
+ * grounding check: the source documents are `grounding_source`, and the
530
+ * question is `query`. Once any part of a request is guarded, the guardrail
531
+ * evaluates only the guarded parts. The model still reads every part.
532
+ *
533
+ * The option stays on the part, so in a chat a guarded turn is evaluated
534
+ * again on every later turn.
535
+ *
536
+ * @stability unstable
537
+ * @category models
538
+ * @since 0.4.0
539
+ */
540
+ export type GuardContent = true | {
541
+ readonly qualifiers: ReadonlyArray<GuardContentQualifier>
542
+ }
543
+
544
+ interface GuardableOptions {
545
+ readonly amazonBedrock?: {
546
+ /**
547
+ * Marks the end of the reusable prefix of the request. The cache point is
548
+ * emitted after the content this is attached to.
549
+ */
550
+ readonly cachePoint?: CachePoint | null
551
+ /**
552
+ * Sends the text as a `guardContent` block, for the guardrail to evaluate.
553
+ * Honoured on user text parts and system messages. On an assistant text
554
+ * part it fails the request with `InvalidUserInputError`.
555
+ */
556
+ readonly guardContent?: GuardContent | null
557
+ } | null
558
+ }
559
+
560
+ declare module "effect/ai/Prompt" {
561
+ /**
562
+ * Amazon Bedrock options for system messages.
563
+ *
564
+ * @stability unstable
565
+ * @category models
566
+ * @since 0.1.0
567
+ */
568
+ export interface SystemMessageOptions extends GuardableOptions {}
569
+
570
+ /**
571
+ * Amazon Bedrock options for text prompt parts.
572
+ *
573
+ * @stability unstable
574
+ * @category models
575
+ * @since 0.1.0
576
+ */
577
+ export interface TextPartOptions extends GuardableOptions {}
578
+ }
579
+
580
+ const textBlock = (
581
+ text: string,
582
+ options: GuardableOptions
583
+ ): { readonly text: string } | { readonly guardContent: typeof GuardrailConverseContentBlock.Encoded } => {
584
+ const guard = options.amazonBedrock?.guardContent
585
+ if (Predicate.isNullish(guard)) {
586
+ return { text }
587
+ }
588
+ return { guardContent: { text: guard === true ? { text } : { text, qualifiers: guard.qualifiers } } }
589
+ }
590
+
591
+ /**
592
+ * What a guardrail did to one side of the exchange.
593
+ *
594
+ * @stability unstable
595
+ * @category models
596
+ * @since 0.4.0
597
+ */
598
+ export type GuardrailAction = "blocked" | "masked" | "none"
599
+
600
+ /**
601
+ * What the guardrails did, read from the trace.
602
+ *
603
+ * **Details**
604
+ *
605
+ * `input` and `output` are the strongest action that any guardrail took on
606
+ * that side, `blocked` over `masked` over `none`, across every guardrail in
607
+ * the trace, enforced guardrails included. `policies` lists the trace keys of
608
+ * the policies that blocked or masked, such as `wordPolicy` or
609
+ * `sensitiveInformationPolicy`, each once, in the order the trace names them.
610
+ *
611
+ * @stability unstable
612
+ * @category models
613
+ * @since 0.4.0
614
+ */
615
+ export type GuardrailOutcome = {
616
+ readonly input: GuardrailAction
617
+ readonly output: GuardrailAction
618
+ // Mutable, because the same type rides on ContentPolicyError, whose
619
+ // metadata must be MutableJson.
620
+ readonly policies: Array<string>
621
+ }
622
+
623
+ /**
624
+ * The Amazon Bedrock metadata on every finish part.
625
+ *
626
+ * **Details**
627
+ *
628
+ * `stopReason` is the raw Converse stop reason. It tells
629
+ * `guardrail_intervened` from `content_filtered`, which both finish with
630
+ * `"content-filter"`, and `max_tokens` from `model_context_window_exceeded`,
631
+ * which both finish with `"length"`. It is `null` only when a stream ended
632
+ * without a `messageStop` event.
633
+ *
634
+ * `guardrail` is `null` when the response carried no guardrail trace, which
635
+ * is the case unless `trace` is enabled in the guardrail config. A trace in
636
+ * which nothing acted gives `{ input: "none", output: "none", policies: [] }`.
637
+ * `trace` is the Converse trace as Bedrock sent it, or `null`. It holds the
638
+ * text that a policy matched, unmasked.
639
+ *
640
+ * @stability unstable
641
+ * @category models
642
+ * @since 0.4.0
643
+ */
644
+ export type FinishMetadata = {
645
+ readonly stopReason: string | null
646
+ readonly guardrail: GuardrailOutcome | null
647
+ readonly trace: Schema.MutableJson | null
648
+ }
649
+
650
+ declare module "effect/ai/AiError" {
651
+ /**
652
+ * Amazon Bedrock metadata on the `ContentPolicyError` that `generateObject`
653
+ * fails with when a guardrail blocked the exchange. It is the metadata of
654
+ * the response's finish part.
655
+ *
656
+ * @stability unstable
657
+ * @category models
658
+ * @since 0.4.0
659
+ */
660
+ export interface ContentPolicyErrorMetadata extends ProviderMetadata {
661
+ readonly amazonBedrock?: FinishMetadata | null
662
+ }
663
+ }
664
+
665
+ declare module "effect/ai/Response" {
666
+ /**
667
+ * Amazon Bedrock metadata on the finish part.
668
+ *
669
+ * @stability unstable
670
+ * @category models
671
+ * @since 0.4.0
672
+ */
673
+ export interface FinishPartMetadata extends ProviderMetadata {
674
+ readonly amazonBedrock?: FinishMetadata | null
675
+ }
676
+ }
677
+
492
678
  // =============================================================================
493
679
  // Configuration
494
680
  // =============================================================================
@@ -498,12 +684,17 @@ const reasoningTextInfo = (signature: string | undefined): ReasoningInfo => ({
498
684
  *
499
685
  * **Details**
500
686
  *
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`.
687
+ * Derived from `ConverseStreamRequest`, minus the fields built from the
688
+ * prompt, so every Converse request parameter is settable per request. That
689
+ * includes `additionalModelRequestFields`, which carries inference parameters
690
+ * specific to the target model — among them extended thinking on Anthropic
691
+ * models. The thinking budget is drawn from the same ceiling as the visible
692
+ * answer, so `inferenceConfig.maxTokens` must exceed `budget_tokens`.
693
+ *
694
+ * It also includes `guardrailConfig` (see {@link GuardrailConfig}). An
695
+ * override replaces the whole object, and `guardrailConfig: undefined` turns
696
+ * off a layer's guardrail for one request. `generateText` does not send
697
+ * `streamProcessingMode`.
507
698
  *
508
699
  * **Example** (Enabling extended thinking for one request)
509
700
  *
@@ -532,7 +723,7 @@ export class Config extends Context.Service<
532
723
  Simplify<
533
724
  & Partial<
534
725
  Omit<
535
- typeof ConverseRequest.Encoded,
726
+ typeof ConverseStreamRequest.Encoded,
536
727
  "messages" | "system" | "outputConfig"
537
728
  >
538
729
  >
@@ -635,7 +826,7 @@ export const make = Effect.fnUntraced(function*({ config: providerConfig, model
635
826
  function*(
636
827
  options: LanguageModel.ProviderOptions
637
828
  ): Effect.fn.Return<{
638
- readonly request: typeof ConverseRequest.Encoded
829
+ readonly request: typeof ConverseStreamRequest.Encoded
639
830
  readonly nameMapper: Tool.NameMapper<ReadonlyArray<Tool.Any>>
640
831
  readonly documents: ReadonlyArray<CitedDocument>
641
832
  }, AiError.AiError> {
@@ -708,7 +899,7 @@ export const make = Effect.fnUntraced(function*({ config: providerConfig, model
708
899
  }
709
900
  }
710
901
 
711
- const request: typeof ConverseRequest.Encoded = {
902
+ const request: typeof ConverseStreamRequest.Encoded = {
712
903
  ...config,
713
904
  modelId,
714
905
  system,
@@ -736,18 +927,35 @@ export const make = Effect.fnUntraced(function*({ config: providerConfig, model
736
927
  return yield* LanguageModel.make({
737
928
  codecTransformer: toCodecAnthropic,
738
929
  generateText: Effect.fnUntraced(function*(options) {
739
- const { documents, nameMapper, request } = yield* makeRequest(options)
930
+ const { documents, nameMapper, request: streamRequest } = yield* makeRequest(options)
931
+ const request = toConverseRequest(streamRequest)
932
+ annotateRequest(options.span, request)
740
933
  const rawResponse = yield* client.converse({ payload: request })
934
+ // Before `makeResponse`, which fails a guardrail-blocked object with a
935
+ // `ContentPolicyError` after the call has already spent its tokens.
936
+ annotateResponse(options.span, request.modelId, rawResponse.stopReason, rawResponse.usage)
741
937
  return yield* makeResponse(request, rawResponse, options, nameMapper, documents)
742
938
  }),
743
939
  streamText: Effect.fnUntraced(function*(options) {
744
940
  const { documents, nameMapper, request } = yield* makeRequest(options)
941
+ annotateRequest(options.span, request)
745
942
  const stream = client.converseStream({ payload: request })
746
- return yield* makeStreamResponse(request, stream, nameMapper, documents)
943
+ return yield* makeStreamResponse(request, stream, options.tools, nameMapper, documents, options.span)
747
944
  }, (effect, _options) => effect.pipe(Stream.unwrap))
748
945
  })
749
946
  })
750
947
 
948
+ const toConverseRequest = (
949
+ request: typeof ConverseStreamRequest.Encoded
950
+ ): typeof ConverseRequest.Encoded => {
951
+ const { guardrailConfig, ...rest } = request
952
+ if (Predicate.isUndefined(guardrailConfig)) {
953
+ return rest
954
+ }
955
+ const { streamProcessingMode: _, ...converseGuardrail } = guardrailConfig
956
+ return { ...rest, guardrailConfig: converseGuardrail }
957
+ }
958
+
751
959
  /**
752
960
  * Creates a layer for the Amazon Bedrock language model.
753
961
  *
@@ -818,18 +1026,40 @@ const documentFormats: Record<string, typeof DocumentBlock.Encoded["format"]> =
818
1026
  /** Matches any absolute url, so a non-s3 one is rejected instead of being treated as base64 data. */
819
1027
  const urlPattern = /^[a-z][a-z0-9+.-]*:\/\//i
820
1028
 
1029
+ /** Matches the header of a `data:` url, up to the comma before its payload. */
1030
+ const dataUrlPattern = /^data:[^,]*?(;base64)?,/i
1031
+
821
1032
  /**
822
1033
  * Resolves file part data into a Converse media source.
823
1034
  *
824
1035
  * **Details**
825
1036
  *
826
1037
  * String data is already base64 per `Prompt.FilePart`, so it is passed through
827
- * untouched. The only remote source Converse accepts is an S3 location.
1038
+ * untouched. A `data:` url has no `://`, so it is matched on its own, and only
1039
+ * its base64 payload is sent. The only remote source Converse accepts is an S3
1040
+ * location.
828
1041
  */
829
1042
  const fileSource: (
830
1043
  data: typeof Prompt.FilePart.Type["data"]
831
1044
  ) => Effect.Effect<typeof MediaSource.Encoded, AiError.AiError> = Effect.fnUntraced(function*(data) {
832
- if (data instanceof URL || (typeof data === "string" && urlPattern.test(data))) {
1045
+ if (data instanceof Uint8Array) {
1046
+ return { bytes: Base64.encode(data) }
1047
+ }
1048
+ const string = data instanceof URL ? data.href : data
1049
+ const dataUrl = dataUrlPattern.exec(string)
1050
+ if (dataUrl !== null) {
1051
+ if (Predicate.isUndefined(dataUrl[1])) {
1052
+ return yield* AiError.make({
1053
+ module: "AmazonBedrockLanguageModel",
1054
+ method: "prepareMessages",
1055
+ reason: new AiError.InvalidUserInputError({
1056
+ description: `Unsupported file url '${dataUrl[0]}...' - a data url must have a base64 payload`
1057
+ })
1058
+ })
1059
+ }
1060
+ return { bytes: string.slice(dataUrl[0].length) }
1061
+ }
1062
+ if (data instanceof URL || urlPattern.test(data)) {
833
1063
  const url = data instanceof URL ? data : new URL(data)
834
1064
  if (url.protocol !== "s3:") {
835
1065
  return yield* AiError.make({
@@ -842,7 +1072,7 @@ const fileSource: (
842
1072
  }
843
1073
  return { s3Location: { uri: url.toString() } }
844
1074
  }
845
- return { bytes: typeof data === "string" ? data : Base64.encode(data) }
1075
+ return { bytes: data }
846
1076
  })
847
1077
 
848
1078
  /**
@@ -871,7 +1101,7 @@ const documentSource: (
871
1101
  if (data instanceof Uint8Array) {
872
1102
  return { text: new TextDecoder().decode(data) }
873
1103
  }
874
- const decoded = Base64.decodeString(data as string)
1104
+ const decoded = Base64.decodeString(source.bytes)
875
1105
  if (Result.isFailure(decoded)) {
876
1106
  return yield* AiError.make({
877
1107
  module: "AmazonBedrockLanguageModel",
@@ -902,6 +1132,45 @@ const documentName = (fileName: string | undefined, position: number): string =>
902
1132
  return `document ${position}`
903
1133
  }
904
1134
 
1135
+ const guardedImageFormats: Record<string, typeof GuardrailConverseImageBlock.Encoded["format"]> = {
1136
+ "image/jpeg": "jpeg",
1137
+ "image/png": "png"
1138
+ }
1139
+
1140
+ const guardedImage: (
1141
+ part: Prompt.FilePart
1142
+ ) => Effect.Effect<typeof GuardrailConverseImageBlock.Encoded, AiError.AiError> = Effect.fnUntraced(
1143
+ function*(part) {
1144
+ const format = guardedImageFormats[part.mediaType]
1145
+ if (Predicate.isUndefined(format)) {
1146
+ const reason = part.mediaType === "image/*"
1147
+ ? "its format is unknown, and a guarded image must be image/png or image/jpeg"
1148
+ : part.mediaType.startsWith("image/")
1149
+ ? "a guarded image must be image/png or image/jpeg"
1150
+ : "only text parts and png or jpeg images can be guarded"
1151
+ return yield* AiError.make({
1152
+ module: "AmazonBedrockLanguageModel",
1153
+ method: "prepareMessages",
1154
+ reason: new AiError.InvalidUserInputError({
1155
+ description: `Unsupported guardContent on a file part of media type '${part.mediaType}' - ${reason}`
1156
+ })
1157
+ })
1158
+ }
1159
+ const source = yield* fileSource(part.data)
1160
+ if (Predicate.isUndefined(source.bytes)) {
1161
+ return yield* AiError.make({
1162
+ module: "AmazonBedrockLanguageModel",
1163
+ method: "prepareMessages",
1164
+ reason: new AiError.InvalidUserInputError({
1165
+ description: `Unsupported guardContent on an image at '${source.s3Location?.uri}' - a guarded image ` +
1166
+ "must be sent as bytes or base64, not as an s3:// location"
1167
+ })
1168
+ })
1169
+ }
1170
+ return { format, source: { bytes: source.bytes } }
1171
+ }
1172
+ )
1173
+
905
1174
  const prepareMessages: (options: LanguageModel.ProviderOptions) => Effect.Effect<{
906
1175
  readonly system: ReadonlyArray<typeof SystemContentBlock.Encoded>
907
1176
  readonly messages: ReadonlyArray<typeof Message.Encoded>
@@ -933,7 +1202,7 @@ const prepareMessages: (options: LanguageModel.ProviderOptions) => Effect.Effect
933
1202
  })
934
1203
  }
935
1204
  for (const message of group.messages) {
936
- system.push({ text: message.content })
1205
+ system.push(textBlock(message.content, message.options))
937
1206
  const cachePoint = getCachePoint(message)
938
1207
  if (Predicate.isNotNull(cachePoint)) {
939
1208
  system.push({ cachePoint })
@@ -950,9 +1219,14 @@ const prepareMessages: (options: LanguageModel.ProviderOptions) => Effect.Effect
950
1219
  case "user": {
951
1220
  for (const part of message.content) {
952
1221
  if (part.type === "text") {
953
- content.push({ text: part.text })
1222
+ content.push(textBlock(part.text, part.options))
954
1223
  pushCachePoint(content, part)
955
1224
  } else if (part.type === "file") {
1225
+ if (part.options.amazonBedrock?.guardContent === true) {
1226
+ content.push({ guardContent: { image: yield* guardedImage(part) } })
1227
+ pushCachePoint(content, part)
1228
+ continue
1229
+ }
956
1230
  const imageFormat = imageFormats[part.mediaType]
957
1231
  if (Predicate.isNotUndefined(imageFormat)) {
958
1232
  content.push({ image: { format: imageFormat, source: yield* fileSource(part.data) } })
@@ -1048,6 +1322,16 @@ const prepareMessages: (options: LanguageModel.ProviderOptions) => Effect.Effect
1048
1322
  const isLastPart = k === message.content.length - 1
1049
1323
 
1050
1324
  if (part.type === "text") {
1325
+ if (!Predicate.isNullish(part.options.amazonBedrock?.guardContent)) {
1326
+ return yield* AiError.make({
1327
+ module: "AmazonBedrockLanguageModel",
1328
+ method: "prepareMessages",
1329
+ reason: new AiError.InvalidUserInputError({
1330
+ description: "Unsupported guardContent on an assistant text part - this provider guards only " +
1331
+ "user text parts, user png and jpeg images, and system messages"
1332
+ })
1333
+ })
1334
+ }
1051
1335
  // Skip empty text blocks
1052
1336
  if (part.text.trim().length === 0) {
1053
1337
  continue
@@ -1129,6 +1413,26 @@ const tryToolJsonSchema = (tool: Tool.Any, method: string) =>
1129
1413
  catch: (error) => unsupportedSchemaError(error, method)
1130
1414
  })
1131
1415
 
1416
+ // Bedrock is sent each tool's codec schema, which differs from the tool's own
1417
+ // schema wherever the codec rewrites a parameter. Params that follow the codec
1418
+ // are re-encoded with the tool's schema for the Toolkit to decode. Any others
1419
+ // are left as they are, so that the Toolkit reports the error.
1420
+ const decodeToolParams = (
1421
+ tools: ReadonlyArray<Tool.Any>,
1422
+ toolName: string,
1423
+ params: unknown
1424
+ ): Effect.Effect<unknown> => {
1425
+ const tool = tools.find((tool) => tool.name === toolName)
1426
+ if (Predicate.isUndefined(tool)) {
1427
+ return Effect.succeed(params)
1428
+ }
1429
+ return Effect.try(() => toCodecAnthropic(tool.parametersSchema).codec).pipe(
1430
+ Effect.flatMap((codec) => Schema.decodeUnknownEffect(codec)(params)),
1431
+ Effect.flatMap((decoded) => Schema.encodeUnknownEffect(tool.parametersSchema)(decoded)),
1432
+ Effect.orElseSucceed(() => params)
1433
+ ) as Effect.Effect<unknown>
1434
+ }
1435
+
1132
1436
  interface ModelCapabilities {
1133
1437
  readonly supportsStructuredOutput: boolean
1134
1438
  readonly supportsForcedToolChoice: boolean
@@ -1326,7 +1630,7 @@ const makeResponse: (
1326
1630
  documents: ReadonlyArray<CitedDocument>
1327
1631
  ) => Effect.Effect<
1328
1632
  Array<Response.PartEncoded>,
1329
- never,
1633
+ AiError.AiError,
1330
1634
  IdGenerator.IdGenerator
1331
1635
  > = Effect.fnUntraced(function*(request, response, options, nameMapper, documents) {
1332
1636
  const idGenerator = yield* IdGenerator.IdGenerator
@@ -1380,11 +1684,12 @@ const makeResponse: (
1380
1684
  }
1381
1685
  continue
1382
1686
  }
1687
+ const name = nameMapper.getCustomName(part.toolUse.name)
1383
1688
  parts.push({
1384
1689
  type: "tool-call",
1385
1690
  id: part.toolUse.toolUseId,
1386
- name: nameMapper.getCustomName(part.toolUse.name),
1387
- params: part.toolUse.input,
1691
+ name,
1692
+ params: yield* decodeToolParams(options.tools, name, part.toolUse.input),
1388
1693
  providerExecuted: false
1389
1694
  })
1390
1695
  } else if (Predicate.isNotUndefined(part.reasoningContent)) {
@@ -1434,6 +1739,22 @@ const makeResponse: (
1434
1739
  : InternalUtilities.resolveFinishReason(response.stopReason)
1435
1740
  const cacheReadTokens = response.usage.cacheReadInputTokens ?? 0
1436
1741
  const cacheWriteTokens = response.usage.cacheWriteInputTokens ?? 0
1742
+ const metadata = InternalGuardrails.finishMetadata(response.stopReason, response.trace)
1743
+
1744
+ if (options.responseFormat.type === "json") {
1745
+ const text = parts.flatMap((part) => part.type === "text" ? [part.text] : []).join("")
1746
+ if (InternalGuardrails.blocksObject(response.stopReason, text)) {
1747
+ return yield* AiError.make({
1748
+ module: "AmazonBedrockLanguageModel",
1749
+ method: "generateObject",
1750
+ reason: new AiError.ContentPolicyError({
1751
+ description: "An Amazon Bedrock guardrail intervened, and the response holds no JSON object. " +
1752
+ `Bedrock answered: ${truncate(text, 200)}`,
1753
+ metadata: { amazonBedrock: metadata }
1754
+ })
1755
+ })
1756
+ }
1757
+ }
1437
1758
 
1438
1759
  parts.push({
1439
1760
  type: "finish",
@@ -1453,23 +1774,26 @@ const makeResponse: (
1453
1774
  },
1454
1775
  // `Response.FinishPart` models `response` with `Schema.UndefinedOr`, which
1455
1776
  // requires the key; HTTP response details are not surfaced by the client.
1456
- response: undefined
1777
+ response: undefined,
1778
+ metadata: { amazonBedrock: metadata }
1457
1779
  })
1458
1780
 
1459
1781
  return parts
1460
1782
  })
1461
1783
 
1462
1784
  const makeStreamResponse: (
1463
- request: typeof ConverseRequest.Encoded,
1785
+ request: typeof ConverseStreamRequest.Encoded,
1464
1786
  stream: Stream.Stream<ConverseResponseStreamEvent, AiError.AiError>,
1787
+ tools: ReadonlyArray<Tool.Any>,
1465
1788
  nameMapper: Tool.NameMapper<ReadonlyArray<Tool.Any>>,
1466
- documents: ReadonlyArray<CitedDocument>
1789
+ documents: ReadonlyArray<CitedDocument>,
1790
+ span: Span
1467
1791
  ) => Effect.Effect<
1468
1792
  Stream.Stream<Response.StreamPartEncoded, AiError.AiError>,
1469
1793
  never,
1470
1794
  IdGenerator.IdGenerator
1471
1795
  > = Effect.fnUntraced(
1472
- function*(request, stream, nameMapper, documents) {
1796
+ function*(request, stream, tools, nameMapper, documents, span) {
1473
1797
  // Acquired up front: the stream itself must not carry the requirement.
1474
1798
  const idGenerator = yield* IdGenerator.IdGenerator
1475
1799
 
@@ -1503,6 +1827,7 @@ const makeStreamResponse: (
1503
1827
  // (which AWS sends AFTER messageStop and which carries the usage), so finish
1504
1828
  // reports the real token counts rather than the pre-metadata zeros.
1505
1829
  let finishReason: Response.FinishReason = "unknown"
1830
+ let stopReason: string | null = null
1506
1831
 
1507
1832
  return stream.pipe(
1508
1833
  Stream.mapEffect(Effect.fnUntraced(function*(event) {
@@ -1671,7 +1996,7 @@ const makeStreamResponse: (
1671
1996
  type: "tool-call",
1672
1997
  id: block.id,
1673
1998
  name: block.name,
1674
- params,
1999
+ params: yield* decodeToolParams(tools, block.name, params),
1675
2000
  providerExecuted: false
1676
2001
  })
1677
2002
  }
@@ -1682,6 +2007,7 @@ const makeStreamResponse: (
1682
2007
  // Defer the `finish` part to the trailing `metadata` event (usage
1683
2008
  // is still zero here — metadata has not arrived yet).
1684
2009
  finishReason = InternalUtilities.resolveFinishReason(event.messageStop.stopReason)
2010
+ stopReason = event.messageStop.stopReason
1685
2011
  break
1686
2012
  }
1687
2013
 
@@ -1692,6 +2018,7 @@ const makeStreamResponse: (
1692
2018
  if (Predicate.isNotUndefined(event.metadata.usage.cacheWriteInputTokens)) {
1693
2019
  usage.cacheWriteInputTokens = event.metadata.usage.cacheWriteInputTokens
1694
2020
  }
2021
+ annotateResponse(span, request.modelId, stopReason, event.metadata.usage)
1695
2022
  // Terminal success event: usage is now populated — emit `finish`.
1696
2023
  parts.push({
1697
2024
  type: "finish",
@@ -1710,7 +2037,8 @@ const makeStreamResponse: (
1710
2037
  }
1711
2038
  },
1712
2039
  // See `makeResponse`: the key is required (`Schema.UndefinedOr`).
1713
- response: undefined
2040
+ response: undefined,
2041
+ metadata: { amazonBedrock: InternalGuardrails.finishMetadata(stopReason, event.metadata.trace) }
1714
2042
  })
1715
2043
  break
1716
2044
  }
@@ -1748,6 +2076,41 @@ const makeStreamResponse: (
1748
2076
  }
1749
2077
  )
1750
2078
 
2079
+ // =============================================================================
2080
+ // Telemetry
2081
+ // =============================================================================
2082
+
2083
+ const annotateRequest = (span: Span, request: typeof ConverseStreamRequest.Encoded): void => {
2084
+ addGenAIAnnotations(span, {
2085
+ provider: { name: "aws.bedrock" },
2086
+ operation: { name: "chat" },
2087
+ request: {
2088
+ model: request.modelId,
2089
+ maxTokens: request.inferenceConfig?.maxTokens,
2090
+ temperature: request.inferenceConfig?.temperature,
2091
+ topP: request.inferenceConfig?.topP,
2092
+ stopSequences: request.inferenceConfig?.stopSequences
2093
+ }
2094
+ })
2095
+ }
2096
+
2097
+ const annotateResponse = (span: Span, modelId: string, stopReason: string | null, usage: TokenUsage): void => {
2098
+ const cacheRead = usage.cacheReadInputTokens ?? 0
2099
+ const cacheWrite = usage.cacheWriteInputTokens ?? 0
2100
+ addGenAIAnnotations(span, {
2101
+ response: {
2102
+ model: modelId,
2103
+ finishReasons: stopReason === null ? undefined : [stopReason]
2104
+ },
2105
+ usage: {
2106
+ inputTokens: usage.inputTokens + cacheRead + cacheWrite,
2107
+ outputTokens: usage.outputTokens
2108
+ }
2109
+ })
2110
+ span.attribute("gen_ai.usage.cache_read.input_tokens", cacheRead)
2111
+ span.attribute("gen_ai.usage.cache_write.input_tokens", cacheWrite)
2112
+ }
2113
+
1751
2114
  // =============================================================================
1752
2115
  // Utilities
1753
2116
  // =============================================================================