mirror of https://github.com/openclaw/openclaw.git
feat(amazon-bedrock): add Bedrock Guardrails support (#58588)
* feat(amazon-bedrock): just the kiro plans, need to remove before PR * docs(bedrock-guardrails): add environment setup instructions * docs(bedrock-guardrails): mark environment setup tasks as completed * feat(amazon-bedrock): add trace configuration to guardrail settings * feat(amazon-bedrock): implement guardrail wrapper factory and wire into registration * test(amazon-bedrock): add comprehensive guardrail configuration tests * docs(bedrock): add guardrails configuration documentation * docs(bedrock-guardrails): add comprehensive manual testing guide for Docker deployment * docs(bedrock-guardrails): expand manual testing guide with STS credentials and config options * docs(bedrock-guardrails): complete manual testing verification with 8 test scenarios * chore: remove kiro spec files from PR * fix(docs): correct guardrail config path to plugins.entries.*.config * style: format docs and test files
This commit is contained in:
parent
40b24dfa6b
commit
eee185af99
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>): 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<string, unknown>) =>
|
||||
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<string, unknown> {
|
||||
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<string, unknown> = {};
|
||||
(result.onPayload as (p: Record<string, unknown>) => 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" });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<string, unknown> = {
|
||||
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<string, unknown> | 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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue