mirror of https://github.com/openclaw/openclaw.git
254 lines
8.6 KiB
TypeScript
254 lines
8.6 KiB
TypeScript
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 { registerSingleProviderPlugin } from "../../test/helpers/plugins/plugin-registration.js";
|
|
import amazonBedrockPlugin from "./index.js";
|
|
|
|
type RegisteredProviderPlugin = ReturnType<typeof registerSingleProviderPlugin>;
|
|
|
|
/** Register the amazon-bedrock plugin with an optional pluginConfig override. */
|
|
function registerWithConfig(pluginConfig?: Record<string, unknown>): RegisteredProviderPlugin {
|
|
const providers: RegisteredProviderPlugin[] = [];
|
|
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: RegisteredProviderPlugin) {
|
|
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: RegisteredProviderPlugin,
|
|
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);
|
|
|
|
expect(
|
|
provider.resolveDefaultThinkingLevel?.({
|
|
provider: "amazon-bedrock",
|
|
modelId: "us.anthropic.claude-opus-4-6-v1",
|
|
} as never),
|
|
).toBe("adaptive");
|
|
expect(
|
|
provider.resolveDefaultThinkingLevel?.({
|
|
provider: "amazon-bedrock",
|
|
modelId: "amazon.nova-micro-v1:0",
|
|
} as never),
|
|
).toBeUndefined();
|
|
});
|
|
|
|
it("disables prompt caching for non-Anthropic Bedrock models", () => {
|
|
const provider = registerSingleProviderPlugin(amazonBedrockPlugin);
|
|
const wrapped = provider.wrapStreamFn?.({
|
|
provider: "amazon-bedrock",
|
|
modelId: "amazon.nova-micro-v1:0",
|
|
streamFn: (_model: unknown, _context: unknown, options: Record<string, unknown>) => options,
|
|
} as never);
|
|
|
|
expect(
|
|
wrapped?.(
|
|
{
|
|
api: "openai-completions",
|
|
provider: "amazon-bedrock",
|
|
id: "amazon.nova-micro-v1:0",
|
|
} as never,
|
|
{ messages: [] } as never,
|
|
{},
|
|
),
|
|
).toMatchObject({
|
|
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" });
|
|
});
|
|
});
|
|
});
|