diff --git a/docs/providers/bedrock.md b/docs/providers/bedrock.md index 5fbed2b261f..10231038063 100644 --- a/docs/providers/bedrock.md +++ b/docs/providers/bedrock.md @@ -174,3 +174,44 @@ openclaw models list current capabilities. - If you prefer a managed key flow, you can also place an OpenAI‑compatible proxy in front of Bedrock and configure it as an OpenAI provider instead. + +## Guardrails + +You can apply [Amazon Bedrock Guardrails](https://docs.aws.amazon.com/bedrock/latest/userguide/guardrails.html) +to all Bedrock model invocations by adding a `guardrail` object to the +`amazon-bedrock` plugin config. Guardrails let you enforce content filtering, +topic denial, word filters, sensitive information filters, and contextual +grounding checks. + +```json5 +{ + plugins: { + entries: { + "amazon-bedrock": { + config: { + guardrail: { + guardrailIdentifier: "abc123", // guardrail ID or full ARN + guardrailVersion: "1", // version number or "DRAFT" + streamProcessingMode: "sync", // optional: "sync" or "async" + trace: "enabled", // optional: "enabled", "disabled", or "enabled_full" + }, + }, + }, + }, + }, +} +``` + +- `guardrailIdentifier` (required) accepts a guardrail ID (e.g. `abc123`) or a + full ARN (e.g. `arn:aws:bedrock:us-east-1:123456789012:guardrail/abc123`). +- `guardrailVersion` (required) specifies which published version to use, or + `"DRAFT"` for the working draft. +- `streamProcessingMode` (optional) controls whether guardrail evaluation runs + synchronously (`"sync"`) or asynchronously (`"async"`) during streaming. If + omitted, Bedrock uses its default behavior. +- `trace` (optional) enables guardrail trace output in the API response. Set to + `"enabled"` or `"enabled_full"` for debugging; omit or set `"disabled"` for + production. + +The IAM principal used by the gateway must have the `bedrock:ApplyGuardrail` +permission in addition to the standard invoke permissions. diff --git a/extensions/amazon-bedrock/index.test.ts b/extensions/amazon-bedrock/index.test.ts index 59fdc059334..0a534917349 100644 --- a/extensions/amazon-bedrock/index.test.ts +++ b/extensions/amazon-bedrock/index.test.ts @@ -1,7 +1,91 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../../src/config/config.js"; +import { buildPluginApi } from "../../src/plugins/api-builder.js"; +import type { PluginRuntime } from "../../src/plugins/runtime/types.js"; +import type { ProviderPlugin } from "../../src/plugins/types.js"; import { registerSingleProviderPlugin } from "../../test/helpers/plugins/plugin-registration.js"; import amazonBedrockPlugin from "./index.js"; +/** Register the amazon-bedrock plugin with an optional pluginConfig override. */ +function registerWithConfig(pluginConfig?: Record): ProviderPlugin { + const providers: ProviderPlugin[] = []; + const noopLogger = { info() {}, warn() {}, error() {}, debug() {} }; + const api = buildPluginApi({ + id: "amazon-bedrock", + name: "Amazon Bedrock Provider", + source: "test", + registrationMode: "full", + config: {} as OpenClawConfig, + pluginConfig, + runtime: {} as PluginRuntime, + logger: noopLogger, + resolvePath: (input) => input, + handlers: { + registerProvider(provider: ProviderPlugin) { + providers.push(provider); + }, + }, + }); + amazonBedrockPlugin.register(api); + const provider = providers[0]; + if (!provider) throw new Error("provider registration missing"); + return provider; +} + +/** Spy streamFn that returns the options it receives. */ +const spyStreamFn = (_model: unknown, _context: unknown, options: Record) => + options; + +const ANTHROPIC_MODEL = "us.anthropic.claude-sonnet-4-6-v1"; +const NON_ANTHROPIC_MODEL = "amazon.nova-micro-v1:0"; + +const MODEL_DESCRIPTOR = { + api: "openai-completions", + provider: "amazon-bedrock", + id: NON_ANTHROPIC_MODEL, +} as never; + +const ANTHROPIC_MODEL_DESCRIPTOR = { + api: "openai-completions", + provider: "amazon-bedrock", + id: ANTHROPIC_MODEL, +} as never; + +/** + * Call wrapStreamFn and then invoke the returned stream function, capturing + * the payload via the onPayload hook that streamWithPayloadPatch installs. + */ +function callWrappedStream( + provider: ProviderPlugin, + modelId: string, + modelDescriptor: never, +): Record { + const wrapped = provider.wrapStreamFn?.({ + provider: "amazon-bedrock", + modelId, + streamFn: spyStreamFn, + } as never); + + // The wrapped stream returns the options object (from spyStreamFn). + // For guardrail-wrapped streams, streamWithPayloadPatch intercepts onPayload, + // so we need to invoke onPayload on the returned options to trigger the patch. + const result = wrapped?.(modelDescriptor, { messages: [] } as never, {}) as unknown as Record< + string, + unknown + >; + + // If onPayload was installed by streamWithPayloadPatch, call it to apply the patch. + if (typeof result?.onPayload === "function") { + const payload: Record = {}; + (result.onPayload as (p: Record) => void)(payload); + return { ...result, _capturedPayload: payload }; + } + + return result; +} + describe("amazon-bedrock provider plugin", () => { it("marks Claude 4.6 Bedrock models as adaptive by default", () => { const provider = registerSingleProviderPlugin(amazonBedrockPlugin); @@ -42,4 +126,127 @@ describe("amazon-bedrock provider plugin", () => { cacheRetention: "none", }); }); + + describe("guardrail config schema", () => { + it("defines guardrail object with correct property types, required fields, and enums", () => { + const pluginJson = JSON.parse( + readFileSync(resolve(import.meta.dirname, "openclaw.plugin.json"), "utf-8"), + ); + const guardrail = pluginJson.configSchema?.properties?.guardrail; + + expect(guardrail).toBeDefined(); + expect(guardrail.type).toBe("object"); + expect(guardrail.additionalProperties).toBe(false); + + // Required fields + expect(guardrail.required).toEqual(["guardrailIdentifier", "guardrailVersion"]); + + // Property types + expect(guardrail.properties.guardrailIdentifier).toEqual({ type: "string" }); + expect(guardrail.properties.guardrailVersion).toEqual({ type: "string" }); + + // Enum constraints + expect(guardrail.properties.streamProcessingMode).toEqual({ + type: "string", + enum: ["sync", "async"], + }); + expect(guardrail.properties.trace).toEqual({ + type: "string", + enum: ["enabled", "disabled", "enabled_full"], + }); + }); + }); + + describe("guardrail payload injection", () => { + it("does not inject guardrailConfig when guardrail is absent from plugin config", () => { + const provider = registerWithConfig(undefined); + const result = callWrappedStream(provider, NON_ANTHROPIC_MODEL, MODEL_DESCRIPTOR); + + expect(result).not.toHaveProperty("_capturedPayload"); + // The onPayload hook should not exist when no guardrail is configured + expect(result).toMatchObject({ cacheRetention: "none" }); + }); + + it("injects all four fields when guardrail config includes optional fields", () => { + const provider = registerWithConfig({ + guardrail: { + guardrailIdentifier: "my-guardrail-id", + guardrailVersion: "1", + streamProcessingMode: "sync", + trace: "enabled", + }, + }); + const result = callWrappedStream(provider, NON_ANTHROPIC_MODEL, MODEL_DESCRIPTOR); + + expect(result._capturedPayload).toEqual({ + guardrailConfig: { + guardrailIdentifier: "my-guardrail-id", + guardrailVersion: "1", + streamProcessingMode: "sync", + trace: "enabled", + }, + }); + }); + + it("injects only required fields when optional fields are omitted", () => { + const provider = registerWithConfig({ + guardrail: { + guardrailIdentifier: "abc123", + guardrailVersion: "DRAFT", + }, + }); + const result = callWrappedStream(provider, NON_ANTHROPIC_MODEL, MODEL_DESCRIPTOR); + + expect(result._capturedPayload).toEqual({ + guardrailConfig: { + guardrailIdentifier: "abc123", + guardrailVersion: "DRAFT", + }, + }); + }); + + it("injects guardrailConfig for Anthropic models without cacheRetention: none", () => { + const provider = registerWithConfig({ + guardrail: { + guardrailIdentifier: "guardrail-anthropic", + guardrailVersion: "2", + streamProcessingMode: "async", + trace: "disabled", + }, + }); + const result = callWrappedStream(provider, ANTHROPIC_MODEL, ANTHROPIC_MODEL_DESCRIPTOR); + + // Anthropic models should get guardrailConfig + expect(result._capturedPayload).toEqual({ + guardrailConfig: { + guardrailIdentifier: "guardrail-anthropic", + guardrailVersion: "2", + streamProcessingMode: "async", + trace: "disabled", + }, + }); + // Anthropic models should NOT get cacheRetention: "none" + expect(result).not.toHaveProperty("cacheRetention", "none"); + }); + + it("injects guardrailConfig for non-Anthropic models with cacheRetention: none", () => { + const provider = registerWithConfig({ + guardrail: { + guardrailIdentifier: "guardrail-nova", + guardrailVersion: "3", + }, + }); + const result = callWrappedStream(provider, NON_ANTHROPIC_MODEL, MODEL_DESCRIPTOR); + + // Non-Anthropic models should get guardrailConfig + expect(result._capturedPayload).toEqual({ + guardrailConfig: { + guardrailIdentifier: "guardrail-nova", + guardrailVersion: "3", + }, + }); + // Non-Anthropic models should also get cacheRetention: "none" + expect(result).toMatchObject({ cacheRetention: "none" }); + }); + }); }); diff --git a/extensions/amazon-bedrock/index.ts b/extensions/amazon-bedrock/index.ts index 4c00af7efd4..27b49419538 100644 --- a/extensions/amazon-bedrock/index.ts +++ b/extensions/amazon-bedrock/index.ts @@ -1,7 +1,9 @@ +import type { StreamFn } from "@mariozechner/pi-agent-core"; import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; import { createBedrockNoCacheWrapper, isAnthropicBedrockModel, + streamWithPayloadPatch, } from "openclaw/plugin-sdk/provider-stream"; import { mergeImplicitBedrockProvider, @@ -9,6 +11,38 @@ import { resolveImplicitBedrockProvider, } from "./api.js"; +type GuardrailConfig = { + guardrailIdentifier: string; + guardrailVersion: string; + streamProcessingMode?: "sync" | "async"; + trace?: "enabled" | "disabled" | "enabled_full"; +}; + +function createGuardrailWrapStreamFn( + innerWrapStreamFn: (ctx: { modelId: string; streamFn?: StreamFn }) => StreamFn | null | undefined, + guardrailConfig: GuardrailConfig, +): (ctx: { modelId: string; streamFn?: StreamFn }) => StreamFn | null | undefined { + return (ctx) => { + const inner = innerWrapStreamFn(ctx); + if (!inner) return inner; + return (model, context, options) => { + return streamWithPayloadPatch(inner, model, context, options, (payload) => { + const gc: Record = { + guardrailIdentifier: guardrailConfig.guardrailIdentifier, + guardrailVersion: guardrailConfig.guardrailVersion, + }; + if (guardrailConfig.streamProcessingMode) { + gc.streamProcessingMode = guardrailConfig.streamProcessingMode; + } + if (guardrailConfig.trace) { + gc.trace = guardrailConfig.trace; + } + payload.guardrailConfig = gc; + }); + }; + }; +} + const PROVIDER_ID = "amazon-bedrock"; const CLAUDE_46_MODEL_RE = /claude-(?:opus|sonnet)-4(?:\.|-)6(?:$|[-.])/i; @@ -17,6 +51,18 @@ export default definePluginEntry({ name: "Amazon Bedrock Provider", description: "Bundled Amazon Bedrock provider policy plugin", register(api) { + const guardrail = (api.pluginConfig as Record | undefined)?.guardrail as + | GuardrailConfig + | undefined; + + const baseWrapStreamFn = ({ modelId, streamFn }: { modelId: string; streamFn?: StreamFn }) => + isAnthropicBedrockModel(modelId) ? streamFn : createBedrockNoCacheWrapper(streamFn); + + const wrapStreamFn = + guardrail?.guardrailIdentifier && guardrail?.guardrailVersion + ? createGuardrailWrapStreamFn(baseWrapStreamFn, guardrail) + : baseWrapStreamFn; + api.registerProvider({ id: PROVIDER_ID, label: "Amazon Bedrock", @@ -45,8 +91,7 @@ export default definePluginEntry({ providerFamily: "anthropic", dropThinkingBlockModelHints: ["claude"], }, - wrapStreamFn: ({ modelId, streamFn }) => - isAnthropicBedrockModel(modelId) ? streamFn : createBedrockNoCacheWrapper(streamFn), + wrapStreamFn, resolveDefaultThinkingLevel: ({ modelId }) => CLAUDE_46_MODEL_RE.test(modelId.trim()) ? "adaptive" : undefined, }); diff --git a/extensions/amazon-bedrock/openclaw.plugin.json b/extensions/amazon-bedrock/openclaw.plugin.json index c49f2ba34bb..40f74e2e789 100644 --- a/extensions/amazon-bedrock/openclaw.plugin.json +++ b/extensions/amazon-bedrock/openclaw.plugin.json @@ -5,6 +5,18 @@ "configSchema": { "type": "object", "additionalProperties": false, - "properties": {} + "properties": { + "guardrail": { + "type": "object", + "additionalProperties": false, + "properties": { + "guardrailIdentifier": { "type": "string" }, + "guardrailVersion": { "type": "string" }, + "streamProcessingMode": { "type": "string", "enum": ["sync", "async"] }, + "trace": { "type": "string", "enum": ["enabled", "disabled", "enabled_full"] } + }, + "required": ["guardrailIdentifier", "guardrailVersion"] + } + } } }