Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled

Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11),
free to diverge. Tree copied sans upstream .git; upstream remote added for
future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19.
Preserves docs/ARCHITECTURE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
2026-07-05 09:36:54 +00:00
parent 3216769225
commit bedb527145
21108 changed files with 6010766 additions and 0 deletions

View File

@@ -0,0 +1,11 @@
# OpenClaw Amazon Bedrock Provider
Official OpenClaw provider plugin for Amazon Bedrock. It adds Bedrock model discovery, text generation, embeddings, and guardrail-aware provider routing for agents that use AWS-hosted models.
Install from OpenClaw:
```bash
openclaw plugin add @openclaw/amazon-bedrock-provider
```
Configure AWS credentials and region through your normal OpenClaw credential/profile setup, then select Bedrock models with the `amazon-bedrock/...` provider prefix.

View File

@@ -0,0 +1,10 @@
/**
* Lightweight Amazon Bedrock API barrel for config and discovery consumers.
* Keep runtime streaming exports out of this path so metadata flows stay cheap.
*/
export { mergeImplicitBedrockProvider, resolveBedrockConfigApiKey } from "./discovery-shared.js";
export {
discoverBedrockModels,
resetBedrockDiscoveryCacheForTest,
resolveImplicitBedrockProvider,
} from "./discovery.js";

View File

@@ -0,0 +1,34 @@
/**
* AWS shared config cache refresh helpers for Bedrock. They nudge the AWS SDK
* to re-read profile/SSO config when no static credentials are present.
*/
type SharedIniFileLoader = {
loadSharedConfigFiles(init?: { ignoreCache?: boolean }): Promise<unknown>;
};
function hasStaticAwsCredentialEnv(env: NodeJS.ProcessEnv): boolean {
return Boolean(env.AWS_ACCESS_KEY_ID && env.AWS_SECRET_ACCESS_KEY);
}
/** Return whether Bedrock should refresh the AWS shared config cache before discovery. */
export function shouldRefreshAwsSharedConfigCacheForBedrock(env: NodeJS.ProcessEnv): boolean {
if (env.AWS_BEDROCK_SKIP_AUTH === "1" || env.AWS_BEARER_TOKEN_BEDROCK) {
return false;
}
return !hasStaticAwsCredentialEnv(env);
}
async function loadSharedIniFileLoader(): Promise<SharedIniFileLoader> {
return (await import("@smithy/shared-ini-file-loader")) as SharedIniFileLoader;
}
/** Refresh Smithy shared config files when Bedrock needs default-chain credentials. */
export async function refreshAwsSharedConfigCacheForBedrock(
env: NodeJS.ProcessEnv = process.env,
): Promise<void> {
if (!shouldRefreshAwsSharedConfigCacheForBedrock(env)) {
return;
}
const loader = await loadSharedIniFileLoader();
await loader.loadSharedConfigFiles({ ignoreCache: true });
}

View File

@@ -0,0 +1,54 @@
/**
* Stream option extensions and prompt-cache policy for Amazon Bedrock models.
* Provider registration and runtime streaming share these contracts.
*/
import type { StreamOptions, ThinkingBudgets, ThinkingLevel } from "openclaw/plugin-sdk/llm";
/** How Bedrock thinking output should be displayed to users. */
export type BedrockThinkingDisplay = "summarized" | "omitted";
/** Extra Bedrock-specific stream options accepted by the provider runtime. */
export interface BedrockOptions extends StreamOptions {
region?: string;
profile?: string;
toolChoice?: "auto" | "any" | "none" | { type: "tool"; name: string };
reasoning?: ThinkingLevel;
thinkingBudgets?: ThinkingBudgets;
interleavedThinking?: boolean;
thinkingDisplay?: BedrockThinkingDisplay;
requestMetadata?: Record<string, string>;
bearerToken?: string;
}
function getModelMatchCandidates(modelId: string, modelName?: string): string[] {
const values = modelName ? [modelId, modelName] : [modelId];
return values.flatMap((value) => {
const lower = value.toLowerCase();
return [lower, lower.replace(/[\s_.:]+/g, "-")];
});
}
/** Return whether a Bedrock model is known to support Anthropic prompt caching. */
export function supportsBedrockPromptCaching(modelId: string, modelName?: string): boolean {
const candidates = getModelMatchCandidates(modelId, modelName);
const hasClaudeRef = candidates.some((s) => s.includes("claude"));
if (!hasClaudeRef) {
if (typeof process !== "undefined" && process.env.AWS_BEDROCK_FORCE_CACHE === "1") {
return true;
}
return false;
}
if (candidates.some((s) => s.includes("-4-"))) {
return true;
}
if (candidates.some((s) => s.includes("claude-fable-5"))) {
return true;
}
if (candidates.some((s) => s.includes("claude-3-7-sonnet"))) {
return true;
}
if (candidates.some((s) => s.includes("claude-3-5-haiku"))) {
return true;
}
return false;
}

View File

@@ -0,0 +1,5 @@
/**
* Narrow config compatibility barrel for Amazon Bedrock. Doctor/config code can
* import this without loading runtime provider dependencies.
*/
export { migrateAmazonBedrockLegacyConfig } from "./config-compat.js";

View File

@@ -0,0 +1,82 @@
// Amazon Bedrock tests cover config compat plugin behavior.
import { describe, expect, it } from "vitest";
import { migrateAmazonBedrockLegacyConfig } from "./config-compat.js";
describe("amazon-bedrock config migration", () => {
it("moves legacy models.bedrockDiscovery into plugin-owned discovery config", () => {
const result = migrateAmazonBedrockLegacyConfig({
models: {
mode: "merge",
bedrockDiscovery: {
enabled: true,
region: "us-east-1",
refreshInterval: 3600,
},
},
});
expect(result.config).toEqual({
models: {
mode: "merge",
},
plugins: {
entries: {
"amazon-bedrock": {
config: {
discovery: {
enabled: true,
region: "us-east-1",
refreshInterval: 3600,
},
},
},
},
},
});
expect(result.changes).toEqual([
"Moved models.bedrockDiscovery → plugins.entries.amazon-bedrock.config.discovery.",
]);
});
it("merges missing fields into existing plugin discovery config", () => {
const result = migrateAmazonBedrockLegacyConfig({
models: {
bedrockDiscovery: {
enabled: true,
region: "us-east-1",
providerFilter: ["anthropic"],
},
},
plugins: {
entries: {
"amazon-bedrock": {
config: {
discovery: {
region: "us-west-2",
},
},
},
},
},
});
expect(result.config).toEqual({
plugins: {
entries: {
"amazon-bedrock": {
config: {
discovery: {
enabled: true,
region: "us-west-2",
providerFilter: ["anthropic"],
},
},
},
},
},
});
expect(result.changes).toEqual([
"Merged models.bedrockDiscovery → plugins.entries.amazon-bedrock.config.discovery (filled missing fields from legacy; kept explicit plugin config values).",
]);
});
});

View File

@@ -0,0 +1,112 @@
/**
* Legacy config migration for Amazon Bedrock discovery settings. It moves
* old `models.bedrockDiscovery` config into plugin-local config shape.
*/
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
type JsonRecord = Record<string, unknown>;
const LEGACY_PATH = "models.bedrockDiscovery";
const TARGET_PATH = "plugins.entries.amazon-bedrock.config.discovery";
const BLOCKED_OBJECT_KEYS = new Set(["__proto__", "prototype", "constructor"]);
function isBlockedObjectKey(key: string): boolean {
return BLOCKED_OBJECT_KEYS.has(key);
}
function getRecord(value: unknown): JsonRecord | null {
return isRecord(value) ? value : null;
}
function ensureRecord(root: JsonRecord, key: string): JsonRecord {
const existing = root[key];
if (isRecord(existing)) {
return existing;
}
const next: JsonRecord = {};
root[key] = next;
return next;
}
function mergeMissing(target: JsonRecord, source: JsonRecord): void {
for (const [key, value] of Object.entries(source)) {
if (value === undefined || isBlockedObjectKey(key)) {
continue;
}
const existing = target[key];
if (existing === undefined) {
target[key] = value;
continue;
}
if (isRecord(existing) && isRecord(value)) {
mergeMissing(existing, value);
}
}
}
function cloneRecord<T extends JsonRecord>(value: T | undefined): T {
return { ...value } as T;
}
function resolveLegacyBedrockDiscoveryConfig(raw: unknown): JsonRecord | undefined {
if (!isRecord(raw)) {
return undefined;
}
const models = getRecord(raw.models);
return getRecord(models?.bedrockDiscovery) ?? undefined;
}
function pruneEmptyModelsRoot(root: JsonRecord): void {
const models = getRecord(root.models);
if (models && Object.keys(models).length === 0) {
delete root.models;
}
}
/** Migrate legacy Bedrock discovery config into `plugins.entries.amazon-bedrock.config`. */
export function migrateAmazonBedrockLegacyConfig<T>(raw: T): { config: T; changes: string[] } {
if (!isRecord(raw)) {
return { config: raw, changes: [] };
}
const legacy = resolveLegacyBedrockDiscoveryConfig(raw);
if (!legacy) {
return { config: raw, changes: [] };
}
const nextRoot = structuredClone(raw) as JsonRecord;
const models = ensureRecord(nextRoot, "models");
delete models.bedrockDiscovery;
pruneEmptyModelsRoot(nextRoot);
const changes: string[] = [];
if (Object.keys(legacy).length === 0) {
changes.push(`Removed empty ${LEGACY_PATH}.`);
return { config: nextRoot as T, changes };
}
const plugins = ensureRecord(nextRoot, "plugins");
const entries = ensureRecord(plugins, "entries");
const entry = ensureRecord(entries, "amazon-bedrock");
const config = ensureRecord(entry, "config");
const existing = getRecord(config.discovery) ?? undefined;
if (!existing) {
config.discovery = cloneRecord(legacy);
changes.push(`Moved ${LEGACY_PATH}${TARGET_PATH}.`);
return { config: nextRoot as T, changes };
}
const merged = cloneRecord(existing);
mergeMissing(merged, legacy);
config.discovery = merged;
if (JSON.stringify(merged) !== JSON.stringify(existing)) {
changes.push(
`Merged ${LEGACY_PATH}${TARGET_PATH} (filled missing fields from legacy; kept explicit plugin config values).`,
);
return { config: nextRoot as T, changes };
}
changes.push(`Removed ${LEGACY_PATH} (${TARGET_PATH} already set).`);
return { config: nextRoot as T, changes };
}

View File

@@ -0,0 +1,34 @@
/**
* Shared Amazon Bedrock discovery helpers used by plugin runtime and config
* consumers without pulling in the AWS discovery implementation.
*/
import { resolveAwsSdkEnvVarName } from "openclaw/plugin-sdk/provider-auth-runtime";
import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-shared";
/** Resolve the config auth marker that tells OpenClaw to use AWS SDK credentials. */
export function resolveBedrockConfigApiKey(
env: NodeJS.ProcessEnv = process.env,
): string | undefined {
// When no AWS auth env marker is present, Bedrock should fall back to the
// AWS SDK default credential chain instead of persisting a fake apiKey marker.
return resolveAwsSdkEnvVarName(env);
}
/** Merge an implicit Bedrock provider catalog with any explicit user config. */
export function mergeImplicitBedrockProvider(params: {
existing: ModelProviderConfig | undefined;
implicit: ModelProviderConfig;
}): ModelProviderConfig {
const { existing, implicit } = params;
if (!existing) {
return implicit;
}
return {
...implicit,
...existing,
models:
Array.isArray(existing.models) && existing.models.length > 0
? existing.models
: implicit.models,
};
}

View File

@@ -0,0 +1,772 @@
// Amazon Bedrock tests cover discovery plugin behavior.
import type { BedrockClient } from "@aws-sdk/client-bedrock";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
discoverBedrockModels,
mergeImplicitBedrockProvider,
resetBedrockDiscoveryCacheForTest,
resolveBedrockConfigApiKey,
resolveImplicitBedrockProvider,
} from "./api.js";
const sendMock = vi.fn();
const clientFactory = () => ({ send: sendMock }) as unknown as BedrockClient;
const baseActiveAnthropicSummary = {
modelId: "anthropic.claude-3-7-sonnet-20250219-v1:0",
modelName: "Claude 3.7 Sonnet",
providerName: "anthropic",
inputModalities: ["TEXT"],
outputModalities: ["TEXT"],
responseStreamingSupported: true,
modelLifecycle: { status: "ACTIVE" },
};
function mockSingleActiveSummary(overrides: Partial<typeof baseActiveAnthropicSummary> = {}): void {
sendMock
.mockResolvedValueOnce({
modelSummaries: [{ ...baseActiveAnthropicSummary, ...overrides }],
})
// ListInferenceProfiles response (empty — no inference profiles in basic tests).
.mockResolvedValueOnce({ inferenceProfileSummaries: [] });
}
function expectModelFields(model: unknown, expected: Record<string, unknown>): void {
if (!model || typeof model !== "object") {
throw new Error("Expected model record");
}
const actual = model as Record<string, unknown>;
for (const [key, value] of Object.entries(expected)) {
expect(actual[key]).toEqual(value);
}
}
describe("bedrock discovery", () => {
beforeEach(() => {
sendMock.mockClear();
resetBedrockDiscoveryCacheForTest();
});
afterEach(() => {
resetBedrockDiscoveryCacheForTest();
});
it("filters to active streaming text models and maps modalities", async () => {
sendMock
.mockResolvedValueOnce({
modelSummaries: [
{
modelId: "anthropic.claude-3-7-sonnet-20250219-v1:0",
modelName: "Claude 3.7 Sonnet",
providerName: "anthropic",
inputModalities: ["TEXT", "IMAGE"],
outputModalities: ["TEXT"],
responseStreamingSupported: true,
modelLifecycle: { status: "ACTIVE" },
},
{
modelId: "anthropic.claude-3-haiku-20240307-v1:0",
modelName: "Claude 3 Haiku",
providerName: "anthropic",
inputModalities: ["TEXT"],
outputModalities: ["TEXT"],
responseStreamingSupported: false,
modelLifecycle: { status: "ACTIVE" },
},
{
modelId: "meta.llama3-8b-instruct-v1:0",
modelName: "Llama 3 8B",
providerName: "meta",
inputModalities: ["TEXT"],
outputModalities: ["TEXT"],
responseStreamingSupported: true,
modelLifecycle: { status: "INACTIVE" },
},
{
modelId: "amazon.titan-embed-text-v1",
modelName: "Titan Embed",
providerName: "amazon",
inputModalities: ["TEXT"],
outputModalities: ["EMBEDDING"],
responseStreamingSupported: true,
modelLifecycle: { status: "ACTIVE" },
},
],
})
.mockResolvedValueOnce({ inferenceProfileSummaries: [] });
const models = await discoverBedrockModels({ region: "us-east-1", clientFactory });
expect(models).toHaveLength(1);
expectModelFields(models[0], {
id: "anthropic.claude-3-7-sonnet-20250219-v1:0",
name: "Claude 3.7 Sonnet",
reasoning: false,
input: ["text", "image"],
contextWindow: 200000,
maxTokens: 4096,
});
});
it("applies provider filter", async () => {
mockSingleActiveSummary();
const models = await discoverBedrockModels({
region: "us-east-1",
config: { providerFilter: ["amazon"] },
clientFactory,
});
expect(models).toHaveLength(0);
});
it("uses configured defaults for context and max tokens", async () => {
mockSingleActiveSummary({
modelId: "example.unknown-text-v1:0",
modelName: "Example Unknown Text",
providerName: "example",
});
const models = await discoverBedrockModels({
region: "us-east-1",
config: { defaultContextWindow: 64000, defaultMaxTokens: 8192 },
clientFactory,
});
expectModelFields(models[0], { contextWindow: 64000, maxTokens: 8192 });
});
it("keeps the conservative fallback for unknown inference profiles", async () => {
sendMock
.mockResolvedValueOnce({
modelSummaries: [],
})
.mockResolvedValueOnce({
inferenceProfileSummaries: [
{
inferenceProfileId: "jp.example.unknown-text-v1:0",
inferenceProfileName: "JP Example Unknown Text",
status: "ACTIVE",
type: "SYSTEM_DEFINED",
models: [
{
modelArn:
"arn:aws:bedrock:ap-northeast-1::foundation-model/example.unknown-text-v1:0",
},
],
},
],
});
const models = await discoverBedrockModels({ region: "ap-northeast-1", clientFactory });
expect(models).toHaveLength(1);
expectModelFields(models[0], {
id: "jp.example.unknown-text-v1:0",
contextWindow: 32000,
maxTokens: 4096,
input: ["text"],
});
});
it("marks known Fable inference profile fallbacks as reasoning capable", async () => {
sendMock
.mockResolvedValueOnce({
modelSummaries: [],
})
.mockResolvedValueOnce({
inferenceProfileSummaries: [
{
inferenceProfileId: "us.anthropic.claude-fable-5",
inferenceProfileName: "US Claude Fable 5",
status: "ACTIVE",
type: "SYSTEM_DEFINED",
models: [
{
modelArn: "arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-fable-5",
},
],
},
],
});
const models = await discoverBedrockModels({ region: "us-east-1", clientFactory });
expect(models).toHaveLength(1);
expectModelFields(models[0], {
id: "us.anthropic.claude-fable-5",
reasoning: true,
contextWindow: 1_000_000,
thinkingLevelMap: { off: "low", minimal: "low", xhigh: "xhigh", max: "max" },
});
});
it("skips Mythos Preview inference profiles because Mantle owns that route", async () => {
sendMock
.mockResolvedValueOnce({
modelSummaries: [],
})
.mockResolvedValueOnce({
inferenceProfileSummaries: [
{
inferenceProfileId: "us.anthropic.claude-mythos-preview",
inferenceProfileName: "US Claude Mythos Preview",
status: "ACTIVE",
type: "SYSTEM_DEFINED",
models: [
{
modelArn:
"arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-mythos-preview",
},
],
},
],
});
const models = await discoverBedrockModels({ region: "us-east-1", clientFactory });
expect(models).toEqual([]);
});
it("normalizes region-prefixed versioned model ids when resolving context windows", async () => {
sendMock
.mockResolvedValueOnce({
modelSummaries: [],
})
.mockResolvedValueOnce({
inferenceProfileSummaries: [
{
inferenceProfileId: "jp.anthropic.claude-sonnet-4-6-v1:0",
inferenceProfileName: "JP Claude Sonnet 4.6",
status: "ACTIVE",
type: "SYSTEM_DEFINED",
models: [
{
modelArn:
"arn:aws:bedrock:ap-northeast-1::foundation-model/anthropic.claude-sonnet-4-6-v1:0",
},
],
},
],
});
const models = await discoverBedrockModels({ region: "ap-northeast-1", clientFactory });
expectModelFields(models[0], {
id: "jp.anthropic.claude-sonnet-4-6-v1:0",
contextWindow: 1_000_000,
});
});
it("uses 1M context window for dotted Claude Opus 4.8 Bedrock refs", async () => {
sendMock
.mockResolvedValueOnce({
modelSummaries: [
{
modelId: "anthropic.claude-opus-4.8-v1:0",
modelName: "Claude Opus 4.8",
providerName: "anthropic",
inputModalities: ["TEXT"],
outputModalities: ["TEXT"],
responseStreamingSupported: true,
modelLifecycle: { status: "ACTIVE" },
},
],
})
.mockResolvedValueOnce({
inferenceProfileSummaries: [
{
inferenceProfileId: "us.anthropic.claude-opus-4.8-v1:0",
inferenceProfileName: "US Claude Opus 4.8",
status: "ACTIVE",
type: "SYSTEM_DEFINED",
models: [
{
modelArn:
"arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-opus-4.8-v1:0",
},
],
},
],
});
const models = await discoverBedrockModels({ region: "us-east-1", clientFactory });
expectModelFields(
models.find((model) => model.id === "anthropic.claude-opus-4.8-v1:0"),
{
contextWindow: 1_000_000,
reasoning: true,
thinkingLevelMap: { xhigh: "xhigh", max: "max" },
},
);
expectModelFields(
models.find((model) => model.id === "us.anthropic.claude-opus-4.8-v1:0"),
{
contextWindow: 1_000_000,
reasoning: true,
thinkingLevelMap: { xhigh: "xhigh", max: "max" },
},
);
});
it("applies Fable limits and reasoning metadata to foundation and profile models", async () => {
sendMock
.mockResolvedValueOnce({
modelSummaries: [
{
modelId: "anthropic.claude-fable-5",
modelName: "Claude Fable 5",
providerName: "anthropic",
inputModalities: ["TEXT", "IMAGE"],
outputModalities: ["TEXT"],
responseStreamingSupported: true,
modelLifecycle: { status: "ACTIVE" },
},
],
})
.mockResolvedValueOnce({
inferenceProfileSummaries: [
{
inferenceProfileId: "company-fable",
inferenceProfileName: "Company Fable",
status: "ACTIVE",
type: "APPLICATION",
models: [
{
modelArn: "arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-fable-5",
},
],
},
],
});
const models = await discoverBedrockModels({ region: "us-east-1", clientFactory });
const expected = {
reasoning: true,
contextWindow: 1_000_000,
maxTokens: 128_000,
thinkingLevelMap: { off: "low", minimal: "low", xhigh: "xhigh", max: "max" },
};
expectModelFields(
models.find((model) => model.id === "anthropic.claude-fable-5"),
expected,
);
expectModelFields(
models.find((model) => model.id === "company-fable"),
{
...expected,
params: { canonicalModelId: "claude-fable-5" },
},
);
});
it("caches results when refreshInterval is enabled", async () => {
mockSingleActiveSummary();
await discoverBedrockModels({ region: "us-east-1", clientFactory });
await discoverBedrockModels({ region: "us-east-1", clientFactory });
// 2 calls on first discovery (ListFoundationModels + ListInferenceProfiles), 0 on cached second.
expect(sendMock).toHaveBeenCalledTimes(2);
});
it("skips cache when refreshInterval expiry overflows", async () => {
sendMock
.mockResolvedValueOnce({ modelSummaries: [baseActiveAnthropicSummary] })
.mockResolvedValueOnce({ inferenceProfileSummaries: [] })
.mockResolvedValueOnce({ modelSummaries: [baseActiveAnthropicSummary] })
.mockResolvedValueOnce({ inferenceProfileSummaries: [] });
await discoverBedrockModels({
region: "us-east-1",
config: { refreshInterval: 1 },
now: () => 8_640_000_000_000_000,
clientFactory,
});
await discoverBedrockModels({
region: "us-east-1",
config: { refreshInterval: 1 },
now: () => 8_640_000_000_000_000,
clientFactory,
});
expect(sendMock).toHaveBeenCalledTimes(4);
});
it("skips cache when refreshInterval is 0", async () => {
sendMock
.mockResolvedValueOnce({ modelSummaries: [baseActiveAnthropicSummary] })
.mockResolvedValueOnce({ inferenceProfileSummaries: [] })
.mockResolvedValueOnce({ modelSummaries: [baseActiveAnthropicSummary] })
.mockResolvedValueOnce({ inferenceProfileSummaries: [] });
await discoverBedrockModels({
region: "us-east-1",
config: { refreshInterval: 0 },
clientFactory,
});
await discoverBedrockModels({
region: "us-east-1",
config: { refreshInterval: 0 },
clientFactory,
});
// 2 calls per discovery (ListFoundationModels + ListInferenceProfiles) × 2 runs.
expect(sendMock).toHaveBeenCalledTimes(4);
});
it("resolves the Bedrock config apiKey from AWS auth env vars", () => {
expect(
resolveBedrockConfigApiKey({
AWS_BEARER_TOKEN_BEDROCK: "bearer", // pragma: allowlist secret
AWS_PROFILE: "default",
}),
).toBe("AWS_BEARER_TOKEN_BEDROCK");
// When no AWS env vars are present (e.g. instance role), no marker should be injected.
// The aws-sdk credential chain handles auth at request time. (#49891)
expect(resolveBedrockConfigApiKey({} as NodeJS.ProcessEnv)).toBeUndefined();
// When AWS_PROFILE is explicitly set, it should return the marker.
expect(resolveBedrockConfigApiKey({ AWS_PROFILE: "default" } as NodeJS.ProcessEnv)).toBe(
"AWS_PROFILE",
);
});
it("discovers inference profiles and inherits foundation model capabilities", async () => {
sendMock
.mockResolvedValueOnce({
modelSummaries: [
{
modelId: "anthropic.claude-sonnet-4-6",
modelName: "Claude Sonnet 4.6",
providerName: "anthropic",
inputModalities: ["TEXT", "IMAGE"],
outputModalities: ["TEXT"],
responseStreamingSupported: true,
modelLifecycle: { status: "ACTIVE" },
},
],
})
.mockResolvedValueOnce({
inferenceProfileSummaries: [
{
inferenceProfileId: "us.anthropic.claude-sonnet-4-6",
inferenceProfileName: "US Anthropic Claude Sonnet 4.6",
inferenceProfileArn:
"arn:aws:bedrock:us-east-1::inference-profile/us.anthropic.claude-sonnet-4-6",
status: "ACTIVE",
type: "SYSTEM_DEFINED",
models: [
{
modelArn: "arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-sonnet-4-6",
},
{
modelArn: "arn:aws:bedrock:us-west-2::foundation-model/anthropic.claude-sonnet-4-6",
},
],
},
{
inferenceProfileId: "eu.anthropic.claude-sonnet-4-6",
inferenceProfileName: "EU Anthropic Claude Sonnet 4.6",
inferenceProfileArn:
"arn:aws:bedrock:eu-west-1::inference-profile/eu.anthropic.claude-sonnet-4-6",
status: "ACTIVE",
type: "SYSTEM_DEFINED",
models: [
{
modelArn: "arn:aws:bedrock:eu-west-1::foundation-model/anthropic.claude-sonnet-4-6",
},
],
},
{
inferenceProfileId: "global.anthropic.claude-sonnet-4-6",
inferenceProfileName: "Global Anthropic Claude Sonnet 4.6",
inferenceProfileArn:
"arn:aws:bedrock:us-east-1::inference-profile/global.anthropic.claude-sonnet-4-6",
status: "ACTIVE",
type: "SYSTEM_DEFINED",
models: [
{
modelArn: "arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-sonnet-4-6",
},
],
},
// Inactive profile should be filtered out.
{
inferenceProfileId: "ap.anthropic.claude-sonnet-4-6",
inferenceProfileName: "AP Claude Sonnet 4.6",
status: "LEGACY",
type: "SYSTEM_DEFINED",
models: [],
},
],
});
const models = await discoverBedrockModels({ region: "us-east-1", clientFactory });
// Foundation model + 3 active inference profiles = 4 models.
expect(models).toHaveLength(4);
// Global profiles should be sorted first (recommended for most users).
expect(models[0]?.id).toBe("global.anthropic.claude-sonnet-4-6");
const foundationModel = models.find((m) => m.id === "anthropic.claude-sonnet-4-6");
const usProfile = models.find((m) => m.id === "us.anthropic.claude-sonnet-4-6");
const euProfile = models.find((m) => m.id === "eu.anthropic.claude-sonnet-4-6");
const globalProfile = models.find((m) => m.id === "global.anthropic.claude-sonnet-4-6");
// Foundation model has image input.
expectModelFields(foundationModel, { input: ["text", "image"] });
// Inference profiles inherit image input from the foundation model.
expectModelFields(usProfile, {
name: "US Anthropic Claude Sonnet 4.6",
input: ["text", "image"],
contextWindow: 1000000,
maxTokens: 4096,
params: { canonicalModelId: "claude-sonnet-4-6" },
});
expect(usProfile?.thinkingLevelMap).toBeUndefined();
expectModelFields(euProfile, { input: ["text", "image"] });
expectModelFields(globalProfile, { input: ["text", "image"] });
// Inactive profile should not be present.
expect(models.find((m) => m.id === "ap.anthropic.claude-sonnet-4-6")).toBeUndefined();
});
it("gracefully handles ListInferenceProfiles permission errors", async () => {
sendMock
.mockResolvedValueOnce({
modelSummaries: [baseActiveAnthropicSummary],
})
// Simulate AccessDeniedException for ListInferenceProfiles.
.mockRejectedValueOnce(new Error("AccessDeniedException"));
const models = await discoverBedrockModels({ region: "us-east-1", clientFactory });
// Foundation model should still be discovered despite profile discovery failure.
expect(models).toHaveLength(1);
expect(models[0]?.id).toBe("anthropic.claude-3-7-sonnet-20250219-v1:0");
});
it("keeps matching inference profiles when provider filters are enabled", async () => {
sendMock
.mockResolvedValueOnce({
modelSummaries: [
{
modelId: "anthropic.claude-sonnet-4-6",
modelName: "Claude Sonnet 4.6",
providerName: "anthropic",
inputModalities: ["TEXT", "IMAGE"],
outputModalities: ["TEXT"],
responseStreamingSupported: true,
modelLifecycle: { status: "ACTIVE" },
},
],
})
.mockResolvedValueOnce({
inferenceProfileSummaries: [
{
inferenceProfileId: "global.anthropic.claude-sonnet-4-6",
inferenceProfileName: "Global Anthropic Claude Sonnet 4.6",
status: "ACTIVE",
type: "SYSTEM_DEFINED",
models: [
{
modelArn: "arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-sonnet-4-6",
},
],
},
],
});
const models = await discoverBedrockModels({
region: "us-east-1",
config: { providerFilter: ["anthropic"] },
clientFactory,
});
expect(models.map((model) => model.id)).toEqual([
"global.anthropic.claude-sonnet-4-6",
"anthropic.claude-sonnet-4-6",
]);
});
it("prefers backing model ARNs for application profiles with region-like ids", async () => {
sendMock
.mockResolvedValueOnce({
modelSummaries: [
{
modelId: "anthropic.claude-sonnet-4-6",
modelName: "Claude Sonnet 4.6",
providerName: "anthropic",
inputModalities: ["TEXT", "IMAGE"],
outputModalities: ["TEXT"],
responseStreamingSupported: true,
modelLifecycle: { status: "ACTIVE" },
},
],
})
.mockResolvedValueOnce({
inferenceProfileSummaries: [
{
inferenceProfileId: "us.my-prod-profile",
inferenceProfileName: "Prod Claude Profile",
status: "ACTIVE",
type: "APPLICATION",
models: [
{
modelArn: "arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-sonnet-4-6",
},
],
},
],
});
const models = await discoverBedrockModels({ region: "us-east-1", clientFactory });
const profile = models.find((model) => model.id === "us.my-prod-profile");
expectModelFields(profile, {
id: "us.my-prod-profile",
input: ["text", "image"],
contextWindow: 1000000,
maxTokens: 4096,
});
});
it("uses the resolved base model id for application-profile context fallback", async () => {
sendMock
.mockResolvedValueOnce({
modelSummaries: [],
})
.mockResolvedValueOnce({
inferenceProfileSummaries: [
{
inferenceProfileId: "us.my-prod-profile",
inferenceProfileName: "Prod Claude Profile",
status: "ACTIVE",
type: "APPLICATION",
models: [
{
modelArn:
"arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-opus-4-6-v1:0",
},
],
},
],
});
const models = await discoverBedrockModels({ region: "us-east-1", clientFactory });
expectModelFields(models[0], {
id: "us.my-prod-profile",
contextWindow: 1_000_000,
maxTokens: 4096,
input: ["text"],
params: { canonicalModelId: "claude-opus-4-6-v1:0" },
thinkingLevelMap: { xhigh: null, max: "max" },
});
});
it("merges implicit Bedrock models into explicit provider overrides", () => {
expect(
mergeImplicitBedrockProvider({
existing: {
baseUrl: "https://override.example.com",
headers: { "x-test-header": "1" },
models: [],
},
implicit: {
baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com",
api: "bedrock-converse-stream",
auth: "aws-sdk",
models: [
{
id: "amazon.nova-micro-v1:0",
name: "Nova",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 1,
maxTokens: 1,
},
],
},
}).models?.map((model) => model.id),
).toEqual(["amazon.nova-micro-v1:0"]);
});
it("uses plugin-owned discovery config without runtime legacy fallback", async () => {
mockSingleActiveSummary();
const pluginEnabled = await resolveImplicitBedrockProvider({
pluginConfig: {
discovery: {
enabled: true,
region: "us-east-1",
},
},
env: {} as NodeJS.ProcessEnv,
clientFactory,
});
expect(pluginEnabled?.baseUrl).toBe("https://bedrock-runtime.us-east-1.amazonaws.com");
// 2 calls per discovery (ListFoundationModels + ListInferenceProfiles).
expect(sendMock).toHaveBeenCalledTimes(2);
});
// Ported from #65449 by @alickgithub2 — extended to also cover apac. prefix
it("resolves au. and apac. prefixes for regional inference profiles", async () => {
sendMock
.mockResolvedValueOnce({
modelSummaries: [
{
modelId: "anthropic.claude-sonnet-4-6",
modelName: "Claude Sonnet 4.6",
providerName: "anthropic",
inputModalities: ["TEXT", "IMAGE"],
outputModalities: ["TEXT"],
responseStreamingSupported: true,
modelLifecycle: { status: "ACTIVE" },
},
],
})
.mockResolvedValueOnce({
inferenceProfileSummaries: [
{
inferenceProfileId: "au.anthropic.claude-sonnet-4-6",
inferenceProfileName: "AU Anthropic Claude Sonnet 4.6",
inferenceProfileArn:
"arn:aws:bedrock:ap-southeast-2::inference-profile/au.anthropic.claude-sonnet-4-6",
status: "ACTIVE",
type: "SYSTEM_DEFINED",
models: [], // no ARNs — forces the prefix-regex fallback
},
{
inferenceProfileId: "apac.anthropic.claude-sonnet-4-6",
inferenceProfileName: "APAC Anthropic Claude Sonnet 4.6",
inferenceProfileArn:
"arn:aws:bedrock:ap-northeast-1::inference-profile/apac.anthropic.claude-sonnet-4-6",
status: "ACTIVE",
type: "SYSTEM_DEFINED",
models: [],
},
],
});
const models = await discoverBedrockModels({ region: "ap-southeast-2", clientFactory });
// Foundation model + 2 regional inference profiles
expect(models).toHaveLength(3);
const auProfile = models.find((m) => m.id === "au.anthropic.claude-sonnet-4-6");
expectModelFields(auProfile, {
id: "au.anthropic.claude-sonnet-4-6",
name: "AU Anthropic Claude Sonnet 4.6",
input: ["text", "image"],
});
const apacProfile = models.find((m) => m.id === "apac.anthropic.claude-sonnet-4-6");
expectModelFields(apacProfile, {
id: "apac.anthropic.claude-sonnet-4-6",
name: "APAC Anthropic Claude Sonnet 4.6",
input: ["text", "image"],
});
});
});

View File

@@ -0,0 +1,693 @@
/**
* Amazon Bedrock model discovery and implicit provider construction. It merges
* foundation models with inference profiles and caches catalog results.
*/
import type {
BedrockClient,
ListFoundationModelsCommandOutput,
ListInferenceProfilesCommandOutput,
} from "@aws-sdk/client-bedrock";
import { createSubsystemLogger } from "openclaw/plugin-sdk/core";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import {
isFutureDateTimestampMs,
resolveExpiresAtMsFromDurationSeconds,
} from "openclaw/plugin-sdk/number-runtime";
import type {
BedrockDiscoveryConfig,
ModelDefinitionConfig,
ModelProviderConfig,
} from "openclaw/plugin-sdk/provider-model-shared";
import {
resolveClaudeFable5ModelIdentity,
resolveClaudeModelIdentity,
supportsClaudeAdaptiveThinking,
} from "openclaw/plugin-sdk/provider-model-shared";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalLowercaseString,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import { refreshAwsSharedConfigCacheForBedrock } from "./aws-credential-refresh.js";
import { resolveBedrockConfigApiKey } from "./discovery-shared.js";
import { resolveBedrockNativeThinkingLevelMap } from "./thinking-policy.js";
const log = createSubsystemLogger("bedrock-discovery");
const DEFAULT_REFRESH_INTERVAL_SECONDS = 3600;
const DEFAULT_CONTEXT_WINDOW = 32_000;
const DEFAULT_MAX_TOKENS = 4096;
// ---------------------------------------------------------------------------
// Known model context windows (Bedrock API does not expose token limits)
// ---------------------------------------------------------------------------
/**
* Bedrock's ListFoundationModels and GetFoundationModel APIs return no token
* limit information — only model ID, name, modalities, and lifecycle status.
* There is currently no Bedrock API to discover context windows or max output
* tokens programmatically.
*
* This map provides correct context window values for known models so that
* session management, compaction thresholds, and context overflow detection
* work correctly. If AWS adds token metadata to the API in the future, this
* table should become a fallback rather than the primary source.
*
* Inference profile prefixes (us., eu., ap., global.) are stripped before lookup.
*
* Sources: https://docs.aws.amazon.com/bedrock/latest/userguide/models-supported.html
* https://platform.claude.com/docs/en/about-claude/models
*/
const KNOWN_CONTEXT_WINDOWS: Record<string, number> = {
// Anthropic Claude
"anthropic.claude-fable-5": 1_000_000,
"anthropic.claude-3-7-sonnet-20250219-v1:0": 200_000,
"anthropic.claude-opus-4-8": 1_000_000,
"anthropic.claude-opus-4-7": 1_000_000,
"anthropic.claude-opus-4-6-v1": 1_000_000,
"anthropic.claude-opus-4-6-v1:0": 1_000_000,
"anthropic.claude-sonnet-4-6": 1_000_000,
"anthropic.claude-sonnet-4-6-v1:0": 1_000_000,
"anthropic.claude-sonnet-4-5-20250929-v1:0": 200_000,
"anthropic.claude-sonnet-4-20250514-v1:0": 200_000,
"anthropic.claude-opus-4-5-20251101-v1:0": 200_000,
"anthropic.claude-opus-4-1-20250805-v1:0": 200_000,
"anthropic.claude-haiku-4-5-20251001-v1:0": 200_000,
"anthropic.claude-3-5-haiku-20241022-v1:0": 200_000,
"anthropic.claude-3-haiku-20240307-v1:0": 200_000,
// Amazon Nova
"amazon.nova-premier-v1:0": 1_000_000,
"amazon.nova-pro-v1:0": 300_000,
"amazon.nova-lite-v1:0": 300_000,
"amazon.nova-micro-v1:0": 128_000,
"amazon.nova-2-lite-v1:0": 300_000,
// MiniMax
"minimax.minimax-m2.5": 1_000_000,
"minimax.minimax-m2.1": 1_000_000,
"minimax.minimax-m2": 1_000_000,
// Meta Llama 4
"meta.llama4-maverick-17b-instruct-v1:0": 1_000_000,
"meta.llama4-scout-17b-instruct-v1:0": 512_000,
// Meta Llama 3
"meta.llama3-3-70b-instruct-v1:0": 128_000,
"meta.llama3-2-90b-instruct-v1:0": 128_000,
"meta.llama3-2-11b-instruct-v1:0": 128_000,
"meta.llama3-2-3b-instruct-v1:0": 128_000,
"meta.llama3-2-1b-instruct-v1:0": 128_000,
"meta.llama3-1-405b-instruct-v1:0": 128_000,
"meta.llama3-1-70b-instruct-v1:0": 128_000,
"meta.llama3-1-8b-instruct-v1:0": 128_000,
// NVIDIA Nemotron
"nvidia.nemotron-super-3-120b": 256_000,
"nvidia.nemotron-nano-3-30b": 128_000,
"nvidia.nemotron-nano-12b-v2": 128_000,
"nvidia.nemotron-nano-9b-v2": 128_000,
// Mistral
"mistral.mistral-large-3-675b-instruct": 128_000,
"mistral.mistral-large-2407-v1:0": 128_000,
"mistral.mistral-small-2402-v1:0": 32_000,
// DeepSeek
"deepseek.r1-v1:0": 128_000,
"deepseek.v3.2": 128_000,
// Cohere
"cohere.command-r-plus-v1:0": 128_000,
"cohere.command-r-v1:0": 128_000,
// AI21
"ai21.jamba-1-5-large-v1:0": 256_000,
"ai21.jamba-1-5-mini-v1:0": 256_000,
// Google Gemma
"google.gemma-3-27b-it": 128_000,
"google.gemma-3-12b-it": 128_000,
"google.gemma-3-4b-it": 128_000,
// GLM
"zai.glm-5": 128_000,
"zai.glm-4.7": 128_000,
"zai.glm-4.7-flash": 128_000,
// Qwen
"qwen.qwen3-coder-next": 256_000,
"qwen.qwen3-coder-30b-a3b-v1:0": 256_000,
"qwen.qwen3-32b-v1:0": 128_000,
"qwen.qwen3-vl-235b-a22b": 128_000,
};
/**
* Resolve the real context window for a Bedrock model ID.
* Strips inference profile prefixes (us., eu., ap., global.) before lookup.
*/
function resolveKnownContextWindow(modelId: string): number | undefined {
const stripped = modelId.replace(/^(?:us|eu|ap|apac|au|jp|global)\./, "");
const candidates = [modelId, stripped];
for (const candidate of candidates) {
if (resolveClaudeFable5ModelIdentity({ id: candidate })) {
return 1_000_000;
}
if (/(?:^|[/.:])anthropic\.claude-opus-4[.-]8(?:$|[-.:/])/i.test(candidate)) {
return 1_000_000;
}
if (KNOWN_CONTEXT_WINDOWS[candidate] !== undefined) {
return KNOWN_CONTEXT_WINDOWS[candidate];
}
const withoutVersionSuffix = candidate.replace(/:0$/, "");
if (
withoutVersionSuffix !== candidate &&
KNOWN_CONTEXT_WINDOWS[withoutVersionSuffix] !== undefined
) {
return KNOWN_CONTEXT_WINDOWS[withoutVersionSuffix];
}
}
return undefined;
}
function isKnownClaudeMythosPreviewModelId(modelId: string): boolean {
const stripped = modelId.replace(/^(?:us|eu|ap|apac|au|jp|global)\./, "");
return [modelId, stripped].some((candidate) =>
/(?:^|[/.:])anthropic\.claude-mythos-preview(?:$|[-.:/])/i.test(candidate),
);
}
function resolveKnownThinkingLevelMap(
modelId: string,
): ModelDefinitionConfig["thinkingLevelMap"] | undefined {
return resolveBedrockNativeThinkingLevelMap(modelId);
}
function resolveKnownMaxTokens(modelId: string): number | undefined {
return resolveClaudeFable5ModelIdentity({ id: modelId }) ? 128_000 : undefined;
}
const DEFAULT_COST = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
};
type BedrockModelSummary = NonNullable<ListFoundationModelsCommandOutput["modelSummaries"]>[number];
type InferenceProfileSummary = NonNullable<
ListInferenceProfilesCommandOutput["inferenceProfileSummaries"]
>[number];
type BedrockDiscoverySdk = {
createClient(region: string): BedrockClient;
createListFoundationModelsCommand(): unknown;
createListInferenceProfilesCommand(input: { nextToken?: string }): unknown;
};
async function loadBedrockDiscoverySdk(): Promise<BedrockDiscoverySdk> {
const { BedrockClient, ListFoundationModelsCommand, ListInferenceProfilesCommand } =
await import("@aws-sdk/client-bedrock");
return {
createClient: (region) => new BedrockClient({ region }),
createListFoundationModelsCommand: () => new ListFoundationModelsCommand({}),
createListInferenceProfilesCommand: (input) => new ListInferenceProfilesCommand(input),
};
}
function createInjectedClientDiscoverySdk(): BedrockDiscoverySdk {
class ListFoundationModelsCommand {
constructor(readonly input: Record<string, unknown> = {}) {}
}
class ListInferenceProfilesCommand {
constructor(readonly input: Record<string, unknown> = {}) {}
}
return {
createClient() {
throw new Error("clientFactory is required for injected Bedrock discovery commands");
},
createListFoundationModelsCommand: () => new ListFoundationModelsCommand({}),
createListInferenceProfilesCommand: (input) => new ListInferenceProfilesCommand(input),
};
}
type BedrockDiscoveryCacheEntry = {
expiresAt: number;
value?: ModelDefinitionConfig[];
inFlight?: Promise<ModelDefinitionConfig[]>;
};
const discoveryCache = new Map<string, BedrockDiscoveryCacheEntry>();
let hasLoggedBedrockError = false;
// ---------------------------------------------------------------------------
// Helper utilities
// ---------------------------------------------------------------------------
function normalizeProviderFilter(filter?: string[]): string[] {
if (!filter || filter.length === 0) {
return [];
}
const normalized = new Set(
filter
.map((entry) => normalizeOptionalLowercaseString(entry))
.filter((entry): entry is string => Boolean(entry)),
);
return Array.from(normalized).toSorted();
}
function buildCacheKey(params: {
region: string;
providerFilter: string[];
refreshIntervalSeconds: number;
defaultContextWindow: number;
defaultMaxTokens: number;
}): string {
return JSON.stringify(params);
}
function includesTextModalities(modalities?: Array<string>): boolean {
return (modalities ?? []).some((entry) => normalizeOptionalLowercaseString(entry) === "text");
}
function isActive(summary: BedrockModelSummary): boolean {
const status = summary.modelLifecycle?.status;
return typeof status === "string" ? status.toUpperCase() === "ACTIVE" : false;
}
function mapInputModalities(summary: BedrockModelSummary): Array<"text" | "image"> {
const inputs = summary.inputModalities ?? [];
const mapped = new Set<"text" | "image">();
for (const modality of inputs) {
const lower = normalizeOptionalLowercaseString(modality);
if (lower === "text") {
mapped.add("text");
}
if (lower === "image") {
mapped.add("image");
}
}
if (mapped.size === 0) {
mapped.add("text");
}
return Array.from(mapped);
}
function inferReasoningSupport(summary: BedrockModelSummary): boolean {
if (supportsClaudeAdaptiveThinking({ id: summary.modelId })) {
return true;
}
const haystack = normalizeLowercaseStringOrEmpty(
`${summary.modelId ?? ""} ${summary.modelName ?? ""}`,
);
return haystack.includes("reasoning") || haystack.includes("thinking");
}
function resolveDefaultContextWindow(config?: BedrockDiscoveryConfig): number {
const value = Math.floor(config?.defaultContextWindow ?? DEFAULT_CONTEXT_WINDOW);
return value > 0 ? value : DEFAULT_CONTEXT_WINDOW;
}
function resolveDefaultMaxTokens(config?: BedrockDiscoveryConfig): number {
const value = Math.floor(config?.defaultMaxTokens ?? DEFAULT_MAX_TOKENS);
return value > 0 ? value : DEFAULT_MAX_TOKENS;
}
// ---------------------------------------------------------------------------
// Foundation model helpers
// ---------------------------------------------------------------------------
function matchesProviderFilter(summary: BedrockModelSummary, filter: string[]): boolean {
if (filter.length === 0) {
return true;
}
const providerName =
summary.providerName ??
(typeof summary.modelId === "string" ? summary.modelId.split(".")[0] : undefined);
const normalized = normalizeOptionalLowercaseString(providerName);
if (!normalized) {
return false;
}
return filter.includes(normalized);
}
function shouldIncludeSummary(summary: BedrockModelSummary, filter: string[]): boolean {
if (!summary.modelId?.trim()) {
return false;
}
if (!matchesProviderFilter(summary, filter)) {
return false;
}
if (summary.responseStreamingSupported !== true) {
return false;
}
if (isKnownClaudeMythosPreviewModelId(summary.modelId)) {
return false;
}
if (!includesTextModalities(summary.outputModalities)) {
return false;
}
if (!isActive(summary)) {
return false;
}
return true;
}
function toModelDefinition(
summary: BedrockModelSummary,
defaults: { contextWindow: number; maxTokens: number },
): ModelDefinitionConfig {
const id = summary.modelId?.trim() ?? "";
const thinkingLevelMap = resolveKnownThinkingLevelMap(id);
return {
id,
name: summary.modelName?.trim() || id,
reasoning: inferReasoningSupport(summary),
input: mapInputModalities(summary),
cost: DEFAULT_COST,
contextWindow: resolveKnownContextWindow(id) ?? defaults.contextWindow,
maxTokens: resolveKnownMaxTokens(id) ?? defaults.maxTokens,
...(thinkingLevelMap ? { thinkingLevelMap } : {}),
};
}
// ---------------------------------------------------------------------------
// Inference profile helpers
// ---------------------------------------------------------------------------
/**
* Resolve the base foundation model ID from an inference profile.
*
* System-defined profiles use a region prefix:
* "us.anthropic.claude-sonnet-4-6" → "anthropic.claude-sonnet-4-6"
*
* Application profiles carry the model ARN in their models[] array:
* models[0].modelArn = "arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-sonnet-4-6"
* → "anthropic.claude-sonnet-4-6"
*/
function resolveBaseModelId(profile: InferenceProfileSummary): string | undefined {
const firstArn = profile.models?.[0]?.modelArn;
if (firstArn) {
const arnMatch = /foundation-model\/(.+)$/.exec(firstArn);
if (arnMatch) {
return arnMatch[1];
}
}
if (profile.type === "SYSTEM_DEFINED") {
const id = profile.inferenceProfileId ?? "";
const prefixMatch = /^(?:us|eu|ap|apac|au|jp|global)\.(.+)$/i.exec(id);
if (prefixMatch) {
return prefixMatch[1];
}
}
return undefined;
}
/**
* Fetch raw inference profile summaries from the Bedrock control plane.
* Handles pagination. Best-effort: silently returns empty array if IAM lacks
* bedrock:ListInferenceProfiles permission.
*/
async function fetchInferenceProfileSummaries(
client: BedrockClient,
createListInferenceProfilesCommand: BedrockDiscoverySdk["createListInferenceProfilesCommand"],
): Promise<InferenceProfileSummary[]> {
try {
const profiles: InferenceProfileSummary[] = [];
let nextToken: string | undefined;
do {
const response: ListInferenceProfilesCommandOutput = await client.send(
createListInferenceProfilesCommand({ nextToken }) as never,
);
for (const summary of response.inferenceProfileSummaries ?? []) {
profiles.push(summary);
}
nextToken = response.nextToken;
} while (nextToken);
return profiles;
} catch (error) {
log.debug?.("Skipping inference profile discovery", {
error: formatErrorMessage(error),
});
return [];
}
}
/**
* Convert raw inference profile summaries into model definitions.
*
* Each profile inherits capabilities (modalities, reasoning, context window,
* cost) from its underlying foundation model. This ensures that
* "us.anthropic.claude-sonnet-4-6" has the same capabilities as
* "anthropic.claude-sonnet-4-6" — including image input, reasoning support,
* and token limits.
*
* When the foundation model isn't found in the map (e.g. the model is only
* available via inference profiles in this region), safe defaults are used.
*/
function resolveInferenceProfiles(
profiles: InferenceProfileSummary[],
defaults: { contextWindow: number; maxTokens: number },
providerFilter: string[],
foundationModels: Map<string, ModelDefinitionConfig>,
): ModelDefinitionConfig[] {
const discovered: ModelDefinitionConfig[] = [];
for (const profile of profiles) {
if (!profile.inferenceProfileId?.trim()) {
continue;
}
if (profile.status !== "ACTIVE") {
continue;
}
// Apply provider filter: check if any of the underlying models match.
if (providerFilter.length > 0) {
const models = profile.models ?? [];
const matchesFilter = models.some((m) => {
const provider = m.modelArn?.split("/")?.[1]?.split(".")?.[0];
return provider
? providerFilter.includes(normalizeOptionalLowercaseString(provider) ?? "")
: false;
});
if (!matchesFilter) {
continue;
}
}
// Look up the underlying foundation model to inherit its capabilities.
const baseModelId = resolveBaseModelId(profile);
if (isKnownClaudeMythosPreviewModelId(baseModelId ?? profile.inferenceProfileId)) {
continue;
}
const baseModel = baseModelId
? foundationModels.get(normalizeLowercaseStringOrEmpty(baseModelId))
: undefined;
const knownThinkingLevelMap = resolveKnownThinkingLevelMap(
baseModelId ?? profile.inferenceProfileId,
);
const canonicalClaudeId = resolveClaudeModelIdentity({ id: baseModelId });
discovered.push({
id: profile.inferenceProfileId,
name: profile.inferenceProfileName?.trim() || profile.inferenceProfileId,
reasoning:
baseModel?.reasoning ??
supportsClaudeAdaptiveThinking({ id: baseModelId ?? profile.inferenceProfileId }),
input: baseModel?.input ?? ["text"],
cost: baseModel?.cost ?? DEFAULT_COST,
contextWindow:
baseModel?.contextWindow ??
resolveKnownContextWindow(baseModelId ?? profile.inferenceProfileId ?? "") ??
defaults.contextWindow,
maxTokens:
baseModel?.maxTokens ??
resolveKnownMaxTokens(baseModelId ?? profile.inferenceProfileId) ??
defaults.maxTokens,
...(baseModel?.thinkingLevelMap || knownThinkingLevelMap
? { thinkingLevelMap: baseModel?.thinkingLevelMap ?? knownThinkingLevelMap }
: {}),
...(canonicalClaudeId.startsWith("claude-")
? { params: { canonicalModelId: canonicalClaudeId } }
: {}),
});
}
return discovered;
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/** Reset Bedrock discovery cache for tests. */
export function resetBedrockDiscoveryCacheForTest(): void {
discoveryCache.clear();
hasLoggedBedrockError = false;
}
/** Discover Bedrock models and inference profiles for one region/config. */
export async function discoverBedrockModels(params: {
region: string;
config?: BedrockDiscoveryConfig;
now?: () => number;
clientFactory?: (region: string) => BedrockClient;
}): Promise<ModelDefinitionConfig[]> {
const refreshIntervalSeconds = Math.max(
0,
Math.floor(params.config?.refreshInterval ?? DEFAULT_REFRESH_INTERVAL_SECONDS),
);
const providerFilter = normalizeProviderFilter(params.config?.providerFilter);
const defaultContextWindow = resolveDefaultContextWindow(params.config);
const defaultMaxTokens = resolveDefaultMaxTokens(params.config);
const cacheKey = buildCacheKey({
region: params.region,
providerFilter,
refreshIntervalSeconds,
defaultContextWindow,
defaultMaxTokens,
});
const now = params.now?.() ?? Date.now();
if (refreshIntervalSeconds > 0) {
const cached = discoveryCache.get(cacheKey);
if (cached && isFutureDateTimestampMs(cached.expiresAt, { nowMs: now })) {
if (cached.value) {
return cached.value;
}
if (cached.inFlight) {
return cached.inFlight;
}
}
if (cached) {
discoveryCache.delete(cacheKey);
}
}
const sdk = params.clientFactory
? createInjectedClientDiscoverySdk()
: await loadBedrockDiscoverySdk();
const clientFactory = params.clientFactory ?? ((region: string) => sdk.createClient(region));
if (!params.clientFactory) {
await refreshAwsSharedConfigCacheForBedrock();
}
const client = clientFactory(params.region);
const discoveryPromise = (async () => {
// Discover foundation models and inference profiles in parallel.
// Both API calls are independent, but we need the foundation model data
// to resolve inference profile capabilities — so we fetch in parallel,
// then build the lookup map before processing profiles.
const [rawFoundationResponse, profileSummaries] = await Promise.all([
client.send(sdk.createListFoundationModelsCommand() as never),
fetchInferenceProfileSummaries(client, (input) =>
sdk.createListInferenceProfilesCommand(input),
),
]);
const foundationResponse = rawFoundationResponse as ListFoundationModelsCommandOutput;
const discovered: ModelDefinitionConfig[] = [];
const seenIds = new Set<string>();
const foundationModels = new Map<string, ModelDefinitionConfig>();
// Foundation models first — build both the results list and the lookup map.
for (const summary of foundationResponse.modelSummaries ?? []) {
if (!shouldIncludeSummary(summary, providerFilter)) {
continue;
}
const def = toModelDefinition(summary, {
contextWindow: defaultContextWindow,
maxTokens: defaultMaxTokens,
});
discovered.push(def);
const normalizedId = normalizeLowercaseStringOrEmpty(def.id);
seenIds.add(normalizedId);
foundationModels.set(normalizedId, def);
}
// Merge inference profiles — inherit capabilities from foundation models.
const inferenceProfiles = resolveInferenceProfiles(
profileSummaries,
{ contextWindow: defaultContextWindow, maxTokens: defaultMaxTokens },
providerFilter,
foundationModels,
);
for (const profile of inferenceProfiles) {
const normalizedId = normalizeLowercaseStringOrEmpty(profile.id);
if (!seenIds.has(normalizedId)) {
discovered.push(profile);
seenIds.add(normalizedId);
}
}
// Sort: global cross-region profiles first (recommended for most users —
// better capacity, automatic failover, no data sovereignty constraints),
// then remaining profiles/models alphabetically.
return discovered.toSorted((a, b) => {
const aGlobal = a.id.startsWith("global.") ? 0 : 1;
const bGlobal = b.id.startsWith("global.") ? 0 : 1;
if (aGlobal !== bGlobal) {
return aGlobal - bGlobal;
}
return a.name.localeCompare(b.name);
});
})();
if (refreshIntervalSeconds > 0) {
const expiresAt = resolveExpiresAtMsFromDurationSeconds(refreshIntervalSeconds, { nowMs: now });
if (expiresAt !== undefined) {
discoveryCache.set(cacheKey, {
expiresAt,
inFlight: discoveryPromise,
});
}
}
try {
const value = await discoveryPromise;
if (refreshIntervalSeconds > 0) {
const expiresAt = resolveExpiresAtMsFromDurationSeconds(refreshIntervalSeconds, {
nowMs: now,
});
if (expiresAt !== undefined) {
discoveryCache.set(cacheKey, {
expiresAt,
value,
});
}
}
return value;
} catch (error) {
if (refreshIntervalSeconds > 0) {
discoveryCache.delete(cacheKey);
}
if (!hasLoggedBedrockError) {
hasLoggedBedrockError = true;
log.warn("Failed to discover Bedrock models", {
error: formatErrorMessage(error),
});
}
return [];
}
}
/** Resolve the implicit Bedrock provider config from env, plugin config, and discovery. */
export async function resolveImplicitBedrockProvider(params: {
pluginConfig?: { discovery?: BedrockDiscoveryConfig };
env?: NodeJS.ProcessEnv;
clientFactory?: (region: string) => BedrockClient;
}): Promise<ModelProviderConfig | null> {
const env = params.env ?? process.env;
const discoveryConfig = params.pluginConfig?.discovery;
const enabled = discoveryConfig?.enabled;
const hasAwsCreds = resolveBedrockConfigApiKey(env) !== undefined;
if (enabled === false) {
return null;
}
if (enabled !== true && !hasAwsCreds) {
return null;
}
const region = discoveryConfig?.region ?? env.AWS_REGION ?? env.AWS_DEFAULT_REGION ?? "us-east-1";
const models = await discoverBedrockModels({
region,
config: discoveryConfig,
clientFactory: params.clientFactory,
});
if (models.length === 0) {
return null;
}
return {
baseUrl: `https://bedrock-runtime.${region}.amazonaws.com`,
api: "bedrock-converse-stream",
auth: "aws-sdk",
models,
};
}

View File

@@ -0,0 +1,154 @@
// Amazon Bedrock tests cover embedding provider plugin behavior.
import { describe, expect, it, vi } from "vitest";
import { testing, hasAwsCredentials } from "./embedding-provider.js";
describe("hasAwsCredentials", () => {
it("accepts static AWS key credentials without loading the credential chain", async () => {
const loadCredentialProvider = vi.fn();
await expect(
hasAwsCredentials(
{
AWS_ACCESS_KEY_ID: "access-key",
AWS_SECRET_ACCESS_KEY: "secret-key",
},
loadCredentialProvider,
),
).resolves.toBe(true);
expect(loadCredentialProvider).not.toHaveBeenCalled();
});
it("accepts the Bedrock bearer token without loading the credential chain", async () => {
const loadCredentialProvider = vi.fn();
await expect(
hasAwsCredentials(
{
AWS_BEARER_TOKEN_BEDROCK: "bearer-token",
},
loadCredentialProvider,
),
).resolves.toBe(true);
expect(loadCredentialProvider).not.toHaveBeenCalled();
});
it("requires AWS profile credentials to resolve through the credential chain", async () => {
const loadCredentialProvider = vi.fn().mockResolvedValue({
defaultProvider: () => async () => ({ accessKeyId: "resolved-access-key" }),
});
await expect(hasAwsCredentials({ AWS_PROFILE: "work" }, loadCredentialProvider)).resolves.toBe(
true,
);
expect(loadCredentialProvider).toHaveBeenCalledOnce();
});
it("rejects AWS profile markers when the credential chain cannot resolve", async () => {
const loadCredentialProvider = vi.fn().mockResolvedValue({
defaultProvider: () => async () => {
throw new Error("Could not load credentials from any providers");
},
});
await expect(
hasAwsCredentials({ AWS_PROFILE: "missing" }, loadCredentialProvider),
).resolves.toBe(false);
});
it("returns false when the AWS credential provider package is unavailable", async () => {
const loadCredentialProvider = vi.fn().mockResolvedValue(null);
await expect(hasAwsCredentials({}, loadCredentialProvider)).resolves.toBe(false);
});
});
describe("bedrock embedding response parsers", () => {
it("wraps malformed single embedding JSON", () => {
expect(() => testing.parseSingle("titan-v2", "{not json")).toThrow(
"Amazon Bedrock embedding response returned malformed JSON",
);
});
it("wraps malformed batch embedding JSON", () => {
expect(() => testing.parseCohereBatch("cohere-v3", "{not json")).toThrow(
"Amazon Bedrock embedding response returned malformed JSON",
);
});
it("rejects non-object embedding JSON", () => {
expect(() => testing.parseSingle("titan-v2", "[]")).toThrow(
"Amazon Bedrock embedding response returned malformed JSON",
);
});
it("rejects missing single embedding vectors", () => {
expect(() => testing.parseSingle("titan-v2", "{}")).toThrow(
"Amazon Bedrock embedding response returned malformed JSON",
);
});
it("rejects wrong single embedding vector element types", () => {
expect(() => testing.parseSingle("titan-v2", '{"embedding":[1,"bad"]}')).toThrow(
"Amazon Bedrock embedding response returned malformed JSON",
);
});
it("rejects missing batch embedding vectors", () => {
expect(() => testing.parseCohereBatch("cohere-v3", "{}")).toThrow(
"Amazon Bedrock embedding response returned malformed JSON",
);
});
it("rejects wrong batch embedding vector shapes", () => {
expect(() =>
testing.parseCohereBatch("cohere-v3", '{"embeddings":[[1],{"bad":true}]}'),
).toThrow("Amazon Bedrock embedding response returned malformed JSON");
});
});
describe("stripInferenceProfilePrefix", () => {
it("strips global prefix", () => {
expect(testing.stripInferenceProfilePrefix("global.cohere.embed-v4:0")).toBe(
"cohere.embed-v4:0",
);
});
it("strips us prefix", () => {
expect(testing.stripInferenceProfilePrefix("us.cohere.embed-v4:0")).toBe("cohere.embed-v4:0");
});
it("strips eu prefix", () => {
expect(testing.stripInferenceProfilePrefix("eu.cohere.embed-v4:0")).toBe("cohere.embed-v4:0");
});
it("strips ap prefix", () => {
expect(testing.stripInferenceProfilePrefix("ap.cohere.embed-v4:0")).toBe("cohere.embed-v4:0");
});
it("strips apac prefix", () => {
expect(testing.stripInferenceProfilePrefix("apac.cohere.embed-v4:0")).toBe(
"cohere.embed-v4:0",
);
});
it("strips au prefix", () => {
expect(testing.stripInferenceProfilePrefix("au.cohere.embed-v4:0")).toBe("cohere.embed-v4:0");
});
it("strips jp prefix", () => {
expect(testing.stripInferenceProfilePrefix("jp.cohere.embed-v4:0")).toBe("cohere.embed-v4:0");
});
it("returns unchanged model ID without prefix", () => {
expect(testing.stripInferenceProfilePrefix("cohere.embed-v4:0")).toBe("cohere.embed-v4:0");
});
it("returns unchanged model ID for amazon.titan-embed-text-v2:0", () => {
expect(testing.stripInferenceProfilePrefix("amazon.titan-embed-text-v2:0")).toBe(
"amazon.titan-embed-text-v2:0",
);
});
});

View File

@@ -0,0 +1,479 @@
/**
* Amazon Bedrock embedding provider runtime. It normalizes model-specific
* request/response shapes across Titan, Cohere, Nova, and TwelveLabs models.
*/
import {
debugEmbeddingsLog,
sanitizeAndNormalizeEmbedding,
type MemoryEmbeddingProvider,
type MemoryEmbeddingProviderCreateOptions,
} from "openclaw/plugin-sdk/memory-core-host-engine-embeddings";
import {
asOptionalRecord as asRecord,
normalizeLowercaseStringOrEmpty,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import { refreshAwsSharedConfigCacheForBedrock } from "./aws-credential-refresh.js";
// ---------------------------------------------------------------------------
// Types & constants
// ---------------------------------------------------------------------------
type BedrockEmbeddingClient = {
region: string;
model: string;
dimensions?: number;
};
/** Default Bedrock embedding model used when no explicit model is configured. */
export const DEFAULT_BEDROCK_EMBEDDING_MODEL = "amazon.titan-embed-text-v2:0";
/** Request/response format family — each has a different API shape. */
type Family = "titan-v1" | "titan-v2" | "cohere-v3" | "cohere-v4" | "nova" | "twelvelabs";
interface ModelSpec {
maxTokens: number;
dims: number;
validDims?: number[];
family: Family;
}
// ---------------------------------------------------------------------------
// Model catalog
// ---------------------------------------------------------------------------
const MODELS: Record<string, ModelSpec> = {
"amazon.titan-embed-text-v2:0": {
maxTokens: 8192,
dims: 1024,
validDims: [256, 512, 1024],
family: "titan-v2",
},
"amazon.titan-embed-text-v1": { maxTokens: 8000, dims: 1536, family: "titan-v1" },
"amazon.titan-embed-g1-text-02": { maxTokens: 8000, dims: 1536, family: "titan-v1" },
"amazon.titan-embed-image-v1": { maxTokens: 128, dims: 1024, family: "titan-v1" },
"cohere.embed-english-v3": { maxTokens: 512, dims: 1024, family: "cohere-v3" },
"cohere.embed-multilingual-v3": { maxTokens: 512, dims: 1024, family: "cohere-v3" },
"cohere.embed-v4:0": {
maxTokens: 128000,
dims: 1536,
validDims: [256, 384, 512, 768, 1024, 1536],
family: "cohere-v4",
},
"amazon.nova-2-multimodal-embeddings-v1:0": {
maxTokens: 8192,
dims: 1024,
validDims: [256, 384, 1024, 3072],
family: "nova",
},
"twelvelabs.marengo-embed-2-7-v1:0": { maxTokens: 512, dims: 1024, family: "twelvelabs" },
"twelvelabs.marengo-embed-3-0-v1:0": { maxTokens: 512, dims: 512, family: "twelvelabs" },
};
/** Strip AWS inference profile prefix (us., eu., ap., apac., au., jp., global.) from model ID. */
function stripInferenceProfilePrefix(modelId: string): string {
return modelId.replace(/^(?:us|eu|ap|apac|au|jp|global)\./, "");
}
/** Resolve spec, stripping throughput suffixes like `:2:8k` or `:0:512`. */
function resolveSpec(modelId: string): ModelSpec | undefined {
const bare = stripInferenceProfilePrefix(modelId);
if (MODELS[bare]) {
return MODELS[bare];
}
const parts = bare.split(":");
for (let i = parts.length - 1; i >= 1; i--) {
const spec = MODELS[parts.slice(0, i).join(":")];
if (spec) {
return spec;
}
}
return undefined;
}
/** Infer family from model ID prefix when not in catalog. */
function inferFamily(modelId: string): Family {
const id = normalizeLowercaseStringOrEmpty(stripInferenceProfilePrefix(modelId));
if (id.startsWith("amazon.titan-embed-text-v2")) {
return "titan-v2";
}
if (id.startsWith("amazon.titan-embed")) {
return "titan-v1";
}
if (id.startsWith("amazon.nova")) {
return "nova";
}
if (id.startsWith("cohere.embed-v4")) {
return "cohere-v4";
}
if (id.startsWith("cohere.embed")) {
return "cohere-v3";
}
if (id.startsWith("twelvelabs.")) {
return "twelvelabs";
}
return "titan-v1"; // safest default — simplest request format
}
// ---------------------------------------------------------------------------
// AWS SDK lazy loader
// ---------------------------------------------------------------------------
type SdkClient = import("@aws-sdk/client-bedrock-runtime").BedrockRuntimeClient;
type SdkCommand = import("@aws-sdk/client-bedrock-runtime").InvokeModelCommand;
interface AwsSdk {
BedrockRuntimeClient: new (config: { region: string }) => SdkClient;
InvokeModelCommand: new (input: {
modelId: string;
body: string;
contentType: string;
accept: string;
}) => SdkCommand;
}
interface AwsCredentialProviderSdk {
defaultProvider: (init?: { timeout?: number; maxRetries?: number }) => () => Promise<{
accessKeyId?: string;
}>;
}
type AwsCredentialProviderLoader = () => Promise<AwsCredentialProviderSdk | null>;
let sdkCache: AwsSdk | null = null;
let credentialProviderSdkCache: AwsCredentialProviderSdk | null | undefined;
async function loadSdk(): Promise<AwsSdk> {
if (sdkCache) {
return sdkCache;
}
try {
sdkCache = (await import("@aws-sdk/client-bedrock-runtime")) as unknown as AwsSdk;
return sdkCache;
} catch {
throw new Error(
"No API key found for provider bedrock: @aws-sdk/client-bedrock-runtime is not installed. " +
"Install it with: npm install @aws-sdk/client-bedrock-runtime",
);
}
}
async function loadCredentialProviderSdk(): Promise<AwsCredentialProviderSdk | null> {
if (credentialProviderSdkCache !== undefined) {
return credentialProviderSdkCache;
}
try {
credentialProviderSdkCache =
(await import("@aws-sdk/credential-provider-node")) as unknown as AwsCredentialProviderSdk;
} catch {
credentialProviderSdkCache = null;
}
return credentialProviderSdkCache;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const MODEL_PREFIX_RE = /^(?:bedrock|amazon-bedrock|aws)\//;
const REGION_RE = /bedrock-runtime\.([a-z0-9-]+)\./;
function normalizeBedrockEmbeddingModel(model: string): string {
const trimmed = model.trim();
return trimmed ? trimmed.replace(MODEL_PREFIX_RE, "") : DEFAULT_BEDROCK_EMBEDDING_MODEL;
}
function regionFromUrl(url: string | undefined): string | undefined {
return url?.trim() ? REGION_RE.exec(url)?.[1] : undefined;
}
// ---------------------------------------------------------------------------
// Request builders
// ---------------------------------------------------------------------------
function buildBody(family: Family, text: string, dims?: number): string {
switch (family) {
case "titan-v2": {
const b: Record<string, unknown> = { inputText: text };
if (dims != null) {
b.dimensions = dims;
b.normalize = true;
}
return JSON.stringify(b);
}
case "titan-v1":
return JSON.stringify({ inputText: text });
case "nova":
return JSON.stringify({
taskType: "SINGLE_EMBEDDING",
singleEmbeddingParams: {
embeddingPurpose: "GENERIC_INDEX",
embeddingDimension: dims ?? 1024,
text: { truncationMode: "END", value: text },
},
});
case "twelvelabs":
return JSON.stringify({ inputType: "text", text: { inputText: text } });
default:
return JSON.stringify({ inputText: text });
}
}
function buildCohereBody(
family: Family,
texts: string[],
inputType: "search_query" | "search_document",
dims?: number,
): string {
const body: Record<string, unknown> = { texts, input_type: inputType, truncate: "END" };
if (family === "cohere-v4") {
body.embedding_types = ["float"];
if (dims != null) {
body.output_dimension = dims;
}
}
return JSON.stringify(body);
}
// ---------------------------------------------------------------------------
// Response parsers
// ---------------------------------------------------------------------------
type BedrockEmbeddingResponseJson = {
embedding?: unknown;
embeddings?: unknown;
data?: unknown;
};
function parseBedrockEmbeddingResponseJson(raw: string): BedrockEmbeddingResponseJson {
try {
const parsed = JSON.parse(raw) as unknown;
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("Amazon Bedrock embedding response returned malformed JSON");
}
return parsed as BedrockEmbeddingResponseJson;
} catch {
throw new Error("Amazon Bedrock embedding response returned malformed JSON");
}
}
function malformedBedrockEmbeddingResponse(): Error {
return new Error("Amazon Bedrock embedding response returned malformed JSON");
}
function asNumberArray(value: unknown): number[] {
if (!Array.isArray(value)) {
throw malformedBedrockEmbeddingResponse();
}
for (const entry of value) {
if (typeof entry !== "number" || !Number.isFinite(entry)) {
throw malformedBedrockEmbeddingResponse();
}
}
return value;
}
function asNumberArrayBatch(value: unknown): number[][] {
if (!Array.isArray(value)) {
throw malformedBedrockEmbeddingResponse();
}
return value.map((entry) => asNumberArray(entry));
}
function parseSingle(family: Family, raw: string): number[] {
const data = parseBedrockEmbeddingResponseJson(raw);
switch (family) {
case "nova":
return asNumberArray(Array.isArray(data.embeddings) ? data.embeddings[0]?.embedding : null);
case "twelvelabs": {
if (Array.isArray(data.data)) {
return asNumberArray(asRecord(data.data[0])?.embedding);
}
const dataRecord = asRecord(data.data);
if (dataRecord) {
return asNumberArray(dataRecord.embedding);
}
return asNumberArray(data.embedding);
}
default:
return asNumberArray(data.embedding);
}
}
function parseCohereBatch(family: Family, raw: string): number[][] {
const data = parseBedrockEmbeddingResponseJson(raw);
const embeddings = data.embeddings;
if (!embeddings) {
throw malformedBedrockEmbeddingResponse();
}
if (family === "cohere-v4" && !Array.isArray(embeddings)) {
const embeddingRecord = asRecord(embeddings);
if (!embeddingRecord) {
throw malformedBedrockEmbeddingResponse();
}
return asNumberArrayBatch(embeddingRecord.float);
}
return asNumberArrayBatch(embeddings);
}
export const testing = {
parseCohereBatch,
parseSingle,
stripInferenceProfilePrefix,
};
// ---------------------------------------------------------------------------
// Provider
// ---------------------------------------------------------------------------
export async function createBedrockEmbeddingProvider(
options: MemoryEmbeddingProviderCreateOptions,
): Promise<{ provider: MemoryEmbeddingProvider; client: BedrockEmbeddingClient }> {
const client = resolveBedrockEmbeddingClient(options);
const { BedrockRuntimeClient, InvokeModelCommand } = await loadSdk();
const spec = resolveSpec(client.model);
const family = spec?.family ?? inferFamily(client.model);
debugEmbeddingsLog("memory embeddings: bedrock client", {
region: client.region,
model: client.model,
dimensions: client.dimensions,
family,
});
const invoke = async (body: string, signal?: AbortSignal): Promise<string> => {
await refreshAwsSharedConfigCacheForBedrock();
const sdk = new BedrockRuntimeClient({ region: client.region });
try {
const res = await sdk.send(
new InvokeModelCommand({
modelId: client.model,
body,
contentType: "application/json",
accept: "application/json",
}),
signal ? { abortSignal: signal } : undefined,
);
return new TextDecoder().decode(res.body);
} finally {
sdk.destroy();
}
};
const isCohere = family === "cohere-v3" || family === "cohere-v4";
const embedSingle = async (text: string, signal?: AbortSignal): Promise<number[]> => {
const raw = await invoke(buildBody(family, text, client.dimensions), signal);
return sanitizeAndNormalizeEmbedding(parseSingle(family, raw));
};
const embedCohere = async (
texts: string[],
inputType: "search_query" | "search_document",
signal?: AbortSignal,
): Promise<number[][]> => {
const raw = await invoke(buildCohereBody(family, texts, inputType, client.dimensions), signal);
return parseCohereBatch(family, raw).map((e) => sanitizeAndNormalizeEmbedding(e));
};
const embedQuery = async (
text: string,
optionsValue?: { signal?: AbortSignal },
): Promise<number[]> => {
if (!text.trim()) {
return [];
}
if (isCohere) {
return (await embedCohere([text], "search_query", optionsValue?.signal))[0] ?? [];
}
return embedSingle(text, optionsValue?.signal);
};
const embedBatch = async (
texts: string[],
optionsLocal?: { signal?: AbortSignal },
): Promise<number[][]> => {
if (texts.length === 0) {
return [];
}
if (isCohere) {
return embedCohere(texts, "search_document", optionsLocal?.signal);
}
return Promise.all(
texts.map((t) => (t.trim() ? embedSingle(t, optionsLocal?.signal) : Promise.resolve([]))),
);
};
return {
provider: {
id: "bedrock",
model: client.model,
maxInputTokens: spec?.maxTokens,
embedQuery,
embedBatch,
},
client,
};
}
// ---------------------------------------------------------------------------
// Client resolution
// ---------------------------------------------------------------------------
function resolveBedrockEmbeddingClient(
options: MemoryEmbeddingProviderCreateOptions,
): BedrockEmbeddingClient {
const model = normalizeBedrockEmbeddingModel(options.model);
const spec = resolveSpec(model);
const providerConfig = options.config.models?.providers?.["amazon-bedrock"];
const region =
regionFromUrl(options.remote?.baseUrl) ??
regionFromUrl(providerConfig?.baseUrl) ??
process.env.AWS_REGION ??
process.env.AWS_DEFAULT_REGION ??
"us-east-1";
let dimensions: number | undefined;
if (options.outputDimensionality != null) {
if (spec?.validDims && !spec.validDims.includes(options.outputDimensionality)) {
throw new Error(
`Invalid dimensions ${options.outputDimensionality} for ${model}. Valid values: ${spec.validDims.join(", ")}`,
);
}
dimensions = options.outputDimensionality;
} else {
dimensions = spec?.dims;
}
return { region, model, dimensions };
}
// ---------------------------------------------------------------------------
// Credential detection
// ---------------------------------------------------------------------------
export async function hasAwsCredentials(
env: NodeJS.ProcessEnv = process.env,
loadCredentialProvider: AwsCredentialProviderLoader = loadCredentialProviderSdk,
): Promise<boolean> {
if (env.AWS_ACCESS_KEY_ID?.trim() && env.AWS_SECRET_ACCESS_KEY?.trim()) {
return true;
}
if (env.AWS_BEARER_TOKEN_BEDROCK?.trim()) {
return true;
}
const credentialProviderSdk = await loadCredentialProvider();
if (!credentialProviderSdk) {
return false;
}
try {
const credentials = await credentialProviderSdk.defaultProvider({
timeout: 1000,
maxRetries: 0,
})();
return typeof credentials.accessKeyId === "string" && credentials.accessKeyId.trim().length > 0;
} catch {
return false;
}
}
export { testing as __testing };

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,15 @@
/**
* Amazon Bedrock provider plugin entry. Registers runtime streaming, discovery,
* auth, thinking policy, guardrail, and memory embedding hooks.
*/
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import { registerAmazonBedrockPlugin } from "./register.sync.runtime.js";
export default definePluginEntry({
id: "amazon-bedrock",
name: "Amazon Bedrock Provider",
description: "Bundled Amazon Bedrock provider policy plugin",
register(api) {
registerAmazonBedrockPlugin(api);
},
});

View File

@@ -0,0 +1,57 @@
// Amazon Bedrock tests cover lazy import plugin behavior.
import { registerSingleProviderPlugin } from "openclaw/plugin-sdk/plugin-test-runtime";
import { afterEach, describe, expect, it, vi } from "vitest";
function mockBedrockSdkImportTripwire(): () => number {
let importCount = 0;
vi.doMock("@aws-sdk/client-bedrock", () => {
importCount += 1;
throw new Error("Bedrock SDK should not load during plugin registration");
});
return () => importCount;
}
describe("amazon-bedrock lazy imports", () => {
afterEach(() => {
vi.doUnmock("@aws-sdk/client-bedrock");
vi.resetModules();
});
it("registers the runtime plugin without loading the Bedrock SDK", async () => {
const getImportCount = mockBedrockSdkImportTripwire();
const { default: amazonBedrockPlugin } = await import("./index.js");
const provider = await registerSingleProviderPlugin(amazonBedrockPlugin);
expect(provider.id).toBe("amazon-bedrock");
expect(provider.resolveConfigApiKey?.({ env: { AWS_PROFILE: "default" } } as never)).toBe(
"AWS_PROFILE",
);
expect(getImportCount()).toBe(0);
});
it("registers the setup entry without loading the Bedrock SDK", async () => {
const getImportCount = mockBedrockSdkImportTripwire();
const { default: setupPlugin } = await import("./setup-api.js");
const providers: Array<{
id: string;
resolveConfigApiKey?: (params: never) => string | undefined;
}> = [];
setupPlugin.register({
registerProvider(provider: {
id: string;
resolveConfigApiKey?: (params: never) => string | undefined;
}) {
providers.push(provider);
},
registerConfigMigration() {},
} as never);
expect(providers.map((provider) => provider.id)).toEqual(["amazon-bedrock"]);
expect(providers[0]?.resolveConfigApiKey?.({ env: { AWS_PROFILE: "default" } } as never)).toBe(
"AWS_PROFILE",
);
expect(getImportCount()).toBe(0);
});
});

View File

@@ -0,0 +1,106 @@
// Amazon Bedrock tests cover memory embedding adapter plugin behavior.
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const hasAwsCredentialsMock = vi.hoisted(() => vi.fn());
const createBedrockEmbeddingProviderMock = vi.hoisted(() => vi.fn());
vi.mock("./embedding-provider.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./embedding-provider.js")>();
return {
...actual,
hasAwsCredentials: hasAwsCredentialsMock,
createBedrockEmbeddingProvider: createBedrockEmbeddingProviderMock,
};
});
import { bedrockMemoryEmbeddingProviderAdapter } from "./memory-embedding-adapter.js";
function defaultCreateOptions() {
return {
config: {} as Record<string, unknown>,
agentDir: "/tmp/test-agent",
model: "",
};
}
function stubCreate(client: { region: string; model: string; dimensions?: number }) {
createBedrockEmbeddingProviderMock.mockResolvedValue({
provider: {
id: "bedrock",
model: client.model,
embedQuery: async () => [],
embedBatch: async () => [],
},
client,
});
}
describe("bedrockMemoryEmbeddingProviderAdapter", () => {
beforeEach(() => {
hasAwsCredentialsMock.mockReset();
createBedrockEmbeddingProviderMock.mockReset();
});
afterEach(() => {
vi.restoreAllMocks();
});
afterAll(() => {
vi.doUnmock("./embedding-provider.js");
vi.resetModules();
});
it("registers the expected adapter metadata", () => {
expect(bedrockMemoryEmbeddingProviderAdapter.id).toBe("bedrock");
expect(bedrockMemoryEmbeddingProviderAdapter.transport).toBe("remote");
expect(bedrockMemoryEmbeddingProviderAdapter.authProviderId).toBe("amazon-bedrock");
expect(bedrockMemoryEmbeddingProviderAdapter.autoSelectPriority).toBe(60);
expect(bedrockMemoryEmbeddingProviderAdapter.allowExplicitWhenConfiguredAuto).toBe(true);
});
it("throws a missing-api-key sentinel error when AWS credentials are unavailable", async () => {
hasAwsCredentialsMock.mockResolvedValue(false);
await expect(
bedrockMemoryEmbeddingProviderAdapter.create(defaultCreateOptions()),
).rejects.toThrow(/No API key found for provider "bedrock"/);
await expect(
bedrockMemoryEmbeddingProviderAdapter.create(defaultCreateOptions()),
).rejects.toThrow(/AWS credentials are not available/);
expect(createBedrockEmbeddingProviderMock).not.toHaveBeenCalled();
});
it("creates the provider when AWS credentials are available", async () => {
hasAwsCredentialsMock.mockResolvedValue(true);
stubCreate({ region: "us-east-1", model: "amazon.titan-embed-text-v2:0", dimensions: 1024 });
const result = await bedrockMemoryEmbeddingProviderAdapter.create(defaultCreateOptions());
expect(result.provider?.id).toBe("bedrock");
expect(result.runtime).toEqual({
id: "bedrock",
cacheKeyData: {
provider: "bedrock",
region: "us-east-1",
model: "amazon.titan-embed-text-v2:0",
dimensions: 1024,
},
});
expect(createBedrockEmbeddingProviderMock).toHaveBeenCalledOnce();
});
it("lets the auto-select loop skip bedrock when credentials are unavailable", async () => {
hasAwsCredentialsMock.mockResolvedValue(false);
let thrown: unknown;
try {
await bedrockMemoryEmbeddingProviderAdapter.create(defaultCreateOptions());
} catch (err) {
thrown = err;
}
expect(thrown).toBeInstanceOf(Error);
expect(bedrockMemoryEmbeddingProviderAdapter.shouldContinueAutoSelection?.(thrown)).toBe(true);
});
});

View File

@@ -0,0 +1,52 @@
/**
* Memory embedding adapter for Amazon Bedrock. It exposes Bedrock embeddings to
* the memory-core engine and verifies AWS credentials before auto-selection.
*/
import {
isMissingEmbeddingApiKeyError,
type MemoryEmbeddingProviderAdapter,
} from "openclaw/plugin-sdk/memory-core-host-engine-embeddings";
import {
createBedrockEmbeddingProvider,
DEFAULT_BEDROCK_EMBEDDING_MODEL,
hasAwsCredentials,
} from "./embedding-provider.js";
/** Memory-core adapter descriptor for Bedrock embeddings. */
export const bedrockMemoryEmbeddingProviderAdapter: MemoryEmbeddingProviderAdapter = {
id: "bedrock",
defaultModel: DEFAULT_BEDROCK_EMBEDDING_MODEL,
transport: "remote",
authProviderId: "amazon-bedrock",
autoSelectPriority: 60,
allowExplicitWhenConfiguredAuto: true,
shouldContinueAutoSelection: isMissingEmbeddingApiKeyError,
create: async (options) => {
if (!(await hasAwsCredentials())) {
throw new Error(
'No API key found for provider "bedrock". ' +
"AWS credentials are not available. " +
"Set AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY, AWS_PROFILE, or AWS_BEARER_TOKEN_BEDROCK, " +
"configure an EC2/ECS/EKS role, " +
"or set agents.defaults.memorySearch.provider to another provider.",
);
}
const { provider, client } = await createBedrockEmbeddingProvider({
...options,
provider: "bedrock",
fallback: "none",
});
return {
provider,
runtime: {
id: "bedrock",
cacheKeyData: {
provider: "bedrock",
region: client.region,
model: client.model,
dimensions: client.dimensions,
},
},
};
},
};

View File

@@ -0,0 +1,470 @@
{
"name": "@openclaw/amazon-bedrock-provider",
"version": "2026.6.11",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@openclaw/amazon-bedrock-provider",
"version": "2026.6.11",
"dependencies": {
"@aws-sdk/client-bedrock": "3.1078.0",
"@aws-sdk/client-bedrock-runtime": "3.1078.0",
"@aws-sdk/credential-provider-node": "3.972.61",
"@smithy/node-http-handler": "4.9.2",
"@smithy/shared-ini-file-loader": "4.6.5",
"@smithy/types": "4.15.1"
}
},
"node_modules/@aws-sdk/client-bedrock": {
"version": "3.1078.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock/-/client-bedrock-3.1078.0.tgz",
"integrity": "sha512-9nTsfK1iQFsDKJYuQFHAKPWnyhSA3MYhSYSYJXQojAt+d34V+iidEaDzXztMtu7imjC3kjZZAvo0WMybWz0nUg==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.974.26",
"@aws-sdk/credential-provider-node": "^3.972.61",
"@aws-sdk/token-providers": "3.1078.0",
"@aws-sdk/types": "^3.973.15",
"@smithy/core": "^3.29.0",
"@smithy/fetch-http-handler": "^5.6.2",
"@smithy/node-http-handler": "^4.9.2",
"@smithy/types": "^4.15.1",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/client-bedrock-runtime": {
"version": "3.1078.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1078.0.tgz",
"integrity": "sha512-GGIpsHOk+zMRQMgxd+5D7Kfhpe6qzyGP4shGzb7NwYqAEleCW9PgX5xXuVQEESkhGX5kqMkDPfT+gg6PN2gczg==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.974.26",
"@aws-sdk/credential-provider-node": "^3.972.61",
"@aws-sdk/eventstream-handler-node": "^3.972.25",
"@aws-sdk/middleware-eventstream": "^3.972.21",
"@aws-sdk/middleware-websocket": "^3.972.34",
"@aws-sdk/token-providers": "3.1078.0",
"@aws-sdk/types": "^3.973.15",
"@smithy/core": "^3.29.0",
"@smithy/fetch-http-handler": "^5.6.2",
"@smithy/node-http-handler": "^4.9.2",
"@smithy/types": "^4.15.1",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/core": {
"version": "3.974.27",
"resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.27.tgz",
"integrity": "sha512-WRWEgIq6vx+NU6ot3VrRu4Jovj9MIObitSi6of/GV5THDDPccBhivCRNkWJutMM+m3GvdeI3l/UbGNcoOobxOA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/types": "^3.973.15",
"@aws-sdk/xml-builder": "^3.972.33",
"@aws/lambda-invoke-store": "^0.3.0",
"@smithy/core": "^3.29.0",
"@smithy/signature-v4": "^5.6.1",
"@smithy/types": "^4.15.1",
"bowser": "^2.11.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-env": {
"version": "3.972.52",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.52.tgz",
"integrity": "sha512-sxuaHZGHqOgKB8OdL3doXa1NJjqmO60FPfyTnYVKGjX9taRsIEGS9pd+2yALmo06hijZ8L94uSK0kfXZsRmVyA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.974.26",
"@aws-sdk/types": "^3.973.15",
"@smithy/core": "^3.29.0",
"@smithy/types": "^4.15.1",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-http": {
"version": "3.972.54",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.54.tgz",
"integrity": "sha512-e6yz52nq3SpR1oPLcvfsDM7H7k2gIYk/NSn/rwsFqzGXEwr3g0mRMlPbLaKCPCGNZJMU/gZg6/64B3eSam+gBw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.974.26",
"@aws-sdk/types": "^3.973.15",
"@smithy/core": "^3.29.0",
"@smithy/fetch-http-handler": "^5.6.2",
"@smithy/node-http-handler": "^4.9.2",
"@smithy/types": "^4.15.1",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-ini": {
"version": "3.972.59",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.59.tgz",
"integrity": "sha512-9Um/UpruN76AdpiLnvwChVkJJwJ9Vx9ykk/2AeLxxSCM/YYRD8Kkq2towUk9fZQLV7dd9ATlsi87U7hKs0z/iQ==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.974.26",
"@aws-sdk/credential-provider-env": "^3.972.52",
"@aws-sdk/credential-provider-http": "^3.972.54",
"@aws-sdk/credential-provider-login": "^3.972.58",
"@aws-sdk/credential-provider-process": "^3.972.52",
"@aws-sdk/credential-provider-sso": "^3.972.58",
"@aws-sdk/credential-provider-web-identity": "^3.972.58",
"@aws-sdk/nested-clients": "^3.997.26",
"@aws-sdk/types": "^3.973.15",
"@smithy/core": "^3.29.0",
"@smithy/credential-provider-imds": "^4.4.5",
"@smithy/types": "^4.15.1",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-login": {
"version": "3.972.58",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.58.tgz",
"integrity": "sha512-H3q96qF8/DJsPsXMVtMRqSWOc85K5O4zos32untdw+vE5vw0f3a6qJo1YqbND4BsEIKd4iZmzzVUq9kV4LjbHg==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.974.26",
"@aws-sdk/nested-clients": "^3.997.26",
"@aws-sdk/types": "^3.973.15",
"@smithy/core": "^3.29.0",
"@smithy/types": "^4.15.1",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-node": {
"version": "3.972.61",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.61.tgz",
"integrity": "sha512-2U2KHMRCt1dlZoLU3KZR5g5EL4b0h2HHw96SkaUBK7qvEXPZj5rGRO/3ZTeJmh37dIYQuCnA2273rZOQvmsiHw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/credential-provider-env": "^3.972.52",
"@aws-sdk/credential-provider-http": "^3.972.54",
"@aws-sdk/credential-provider-ini": "^3.972.59",
"@aws-sdk/credential-provider-process": "^3.972.52",
"@aws-sdk/credential-provider-sso": "^3.972.58",
"@aws-sdk/credential-provider-web-identity": "^3.972.58",
"@aws-sdk/types": "^3.973.15",
"@smithy/core": "^3.29.0",
"@smithy/credential-provider-imds": "^4.4.5",
"@smithy/types": "^4.15.1",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-process": {
"version": "3.972.52",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.52.tgz",
"integrity": "sha512-Aff9Ebs42lz+Ep1wkS+Nlwh5S0eahakpyskPsuKGjiBJ6ExOjNtxbfKJTKovQtQNgJ7oG1BH6esJwGrbs7qgSA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.974.26",
"@aws-sdk/types": "^3.973.15",
"@smithy/core": "^3.29.0",
"@smithy/types": "^4.15.1",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-sso": {
"version": "3.972.58",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.58.tgz",
"integrity": "sha512-syloC58mXOacUqM2toPNfwd7X3jT+tWj0F/cN7qdW1FQyI0q41J0tPf6DIZ56BF0x82iS9j3ALP45MoBz79YuQ==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.974.26",
"@aws-sdk/nested-clients": "^3.997.26",
"@aws-sdk/token-providers": "3.1078.0",
"@aws-sdk/types": "^3.973.15",
"@smithy/core": "^3.29.0",
"@smithy/types": "^4.15.1",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-web-identity": {
"version": "3.972.58",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.58.tgz",
"integrity": "sha512-pTBImKzcGK+pcMKjL0fAJbnYzzYd1c0UDc7BSIOGNQhF9Nuk66vWlIXfYTYyzNSs+w8Q/vfbbNDDU8zdrouwLg==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.974.26",
"@aws-sdk/nested-clients": "^3.997.26",
"@aws-sdk/types": "^3.973.15",
"@smithy/core": "^3.29.0",
"@smithy/types": "^4.15.1",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/eventstream-handler-node": {
"version": "3.972.25",
"resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.25.tgz",
"integrity": "sha512-df7HN1ozwMrB9+59re9PM7tSLxLAcheMWc5u/KyfCPCAWtN/vP7y7RTUZOy48uT1K9MESisVeOPPzF3O1AW01A==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/types": "^3.973.15",
"@smithy/core": "^3.29.0",
"@smithy/types": "^4.15.1",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/middleware-eventstream": {
"version": "3.972.21",
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.21.tgz",
"integrity": "sha512-HvLgDnxBLaHi9E5K++6Vuk+1+qqn7Pmn8zrlzd+NXH3jBzwujnuzZtAR9WHPkbUGPO92FkoQWj/M1IsdxTlBmQ==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/types": "^3.973.15",
"@smithy/core": "^3.29.0",
"@smithy/types": "^4.15.1",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/middleware-websocket": {
"version": "3.972.34",
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.34.tgz",
"integrity": "sha512-8dxKLu5bC74SLwwoYV8RIiCD48jMbMt1Ccl3m+xtQJKet6QsZ4xzJlK6UDg7QNEzm/ZCUknJfGsBHmhkgOfuIQ==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.974.26",
"@aws-sdk/types": "^3.973.15",
"@smithy/core": "^3.29.0",
"@smithy/fetch-http-handler": "^5.6.2",
"@smithy/signature-v4": "^5.6.1",
"@smithy/types": "^4.15.1",
"tslib": "^2.6.2"
},
"engines": {
"node": ">= 14.0.0"
}
},
"node_modules/@aws-sdk/nested-clients": {
"version": "3.997.26",
"resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.26.tgz",
"integrity": "sha512-Lwe3F6K7bs+jEubp1LbrvzeMBYb5fMazJ1IxV9TtKWPF8CSh67Fmwyq9fLz3NL/k55Dfpuph5Dimw76JFgr+SA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.974.26",
"@aws-sdk/signature-v4-multi-region": "^3.996.38",
"@aws-sdk/types": "^3.973.15",
"@smithy/core": "^3.29.0",
"@smithy/fetch-http-handler": "^5.6.2",
"@smithy/node-http-handler": "^4.9.2",
"@smithy/types": "^4.15.1",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/signature-v4-multi-region": {
"version": "3.996.38",
"resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.38.tgz",
"integrity": "sha512-C379Sk+MiFZCfWZphKlMyLHKxV22OjoGM5KJjj5IJNJcOCWL4IGIpnEGzv1FQiRwhYXfq55SJMfxlqPE08JJ9g==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/types": "^3.973.15",
"@smithy/signature-v4": "^5.6.1",
"@smithy/types": "^4.15.1",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/token-providers": {
"version": "3.1078.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1078.0.tgz",
"integrity": "sha512-/uyXLBGu3Lw1GbBA2X66hcOMnKtMcqAIF+3/eHfxBQmUeXF2sdqozDPrTfEr/TnSd0D6deZar+eVyhEqqWu29w==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.974.26",
"@aws-sdk/nested-clients": "^3.997.26",
"@aws-sdk/types": "^3.973.15",
"@smithy/core": "^3.29.0",
"@smithy/types": "^4.15.1",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/types": {
"version": "3.973.15",
"resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.15.tgz",
"integrity": "sha512-IULn8uBV/SMtmOIANsm4WHXIOtVPBWfOWs3WGL0j/sI+KhaYehvOw0ET+9urnn8MBpiijuU/0JOpuwKOE451PQ==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/types": "^4.15.1",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/xml-builder": {
"version": "3.972.33",
"resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.33.tgz",
"integrity": "sha512-ezbwz9WpuLctm6o7P2t2naDhVVPI5jFGrVefVybhcKGjU57VIyT46pQVO0RI2RYkUdhdj2Z9uSIlAzGZE9NW9A==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/types": "^4.15.1",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws/lambda-invoke-store": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz",
"integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/core": {
"version": "3.29.0",
"resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.29.0.tgz",
"integrity": "sha512-sEvpvkBVoMxjoek35XyJFn2ZD3EJ1RpiZrT47WaZodxzAIWS44zkdvbqGE/ZlugtjiQp62cffYZ9ldyRkjAGnA==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/types": "^4.15.1",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/credential-provider-imds": {
"version": "4.4.5",
"resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.4.5.tgz",
"integrity": "sha512-LnjUTNG0GgQlKIq7IioeOrPaEmC5xOd1WtAz24TLSiYQnWX2uHr53GrFuQhkrJBktPYCMga/NbUOW7hFbSA2Cg==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/core": "^3.29.0",
"@smithy/types": "^4.15.1",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/fetch-http-handler": {
"version": "5.6.2",
"resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.6.2.tgz",
"integrity": "sha512-q96PSDOAGw+X+nuELd7Cjebps0SYr+YlPbviEX9sLVw+VM4M7VV8hn1nL1mGS6urDu33eQ5A7WhlphaDO6kUyQ==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/core": "^3.29.0",
"@smithy/types": "^4.15.1",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/node-http-handler": {
"version": "4.9.2",
"resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.9.2.tgz",
"integrity": "sha512-s0yAIRj6TVfHgl+QzVyqal1KMGZ9B5512IrxKc6+dOpw8fUmFL3CvuAhjv0J+aNjUPfVZ2IhqPEDvkB5Ncx9oA==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/core": "^3.29.0",
"@smithy/types": "^4.15.1",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/shared-ini-file-loader": {
"version": "4.6.5",
"resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.6.5.tgz",
"integrity": "sha512-+X0fxlxHtALV4tBI4b/NZu7pLUh5AfHvCurvWn+Sdm+X7SCm+iWDOBu7ZwqNRI0BdfObkTWzFjUVnHheJaBrpA==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/core": "^3.29.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/signature-v4": {
"version": "5.6.1",
"resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.6.1.tgz",
"integrity": "sha512-SqvuP75p/DmgWWI7jv4kf/UW+V4LFmlUn19s604SgAcRuJRB1vDnWwzZMYCLUcmKxko9wDn6iLgGEIpTNgZbIQ==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/core": "^3.29.0",
"@smithy/types": "^4.15.1",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/types": {
"version": "4.15.1",
"resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.15.1.tgz",
"integrity": "sha512-x3L0XSACF6UYzKpa9biqiRMgvH5+wnFFew9Tm/grFYqgaupPwx/+ojDPpPJM8dZON3S9tjz5U+PQYsCBd1Mw5Q==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/bowser": {
"version": "2.14.1",
"resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz",
"integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==",
"license": "MIT"
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
}
}
}

View File

@@ -0,0 +1,82 @@
{
"id": "amazon-bedrock",
"name": "Amazon Bedrock",
"description": "OpenClaw Amazon Bedrock provider plugin with model discovery, embeddings, and guardrail support.",
"activation": {
"onStartup": false
},
"enabledByDefault": true,
"providers": ["amazon-bedrock"],
"contracts": {
"memoryEmbeddingProviders": ["bedrock"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"discovery": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": { "type": "boolean" },
"region": { "type": "string" },
"providerFilter": {
"type": "array",
"items": { "type": "string" }
},
"refreshInterval": { "type": "integer", "minimum": 0 },
"defaultContextWindow": { "type": "integer", "minimum": 1 },
"defaultMaxTokens": { "type": "integer", "minimum": 1 }
}
},
"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"]
}
}
},
"configContracts": {
"compatibilityMigrationPaths": ["models.bedrockDiscovery"]
},
"uiHints": {
"discovery": {
"label": "Model Discovery",
"help": "Plugin-owned controls for Amazon Bedrock model auto-discovery."
},
"discovery.enabled": {
"label": "Enable Discovery",
"help": "When false, OpenClaw keeps the Amazon Bedrock plugin available but skips implicit startup discovery. When true, discovery can run even without AWS auth env markers."
},
"discovery.region": {
"label": "Discovery Region",
"help": "AWS region to use for Bedrock model discovery. Defaults to AWS_REGION, AWS_DEFAULT_REGION, then us-east-1."
},
"discovery.providerFilter": {
"label": "Provider Filter",
"help": "Optional Bedrock provider-name allowlist for discovery, such as anthropic or amazon."
},
"discovery.refreshInterval": {
"label": "Discovery Refresh Interval (s)",
"help": "How long to cache Bedrock discovery results in seconds. Set to 0 to disable caching."
},
"discovery.defaultContextWindow": {
"label": "Default Context Window",
"help": "Fallback context window to assign to discovered Bedrock models."
},
"discovery.defaultMaxTokens": {
"label": "Default Max Tokens",
"help": "Fallback max output tokens to assign to discovered Bedrock models."
},
"guardrail": {
"label": "Guardrail",
"help": "Amazon Bedrock Guardrails settings applied to Bedrock model invocations."
}
}
}

View File

@@ -0,0 +1,42 @@
{
"name": "@openclaw/amazon-bedrock-provider",
"version": "2026.6.11",
"description": "OpenClaw Amazon Bedrock provider plugin with model discovery, embeddings, and guardrail support.",
"repository": {
"type": "git",
"url": "https://github.com/openclaw/openclaw"
},
"type": "module",
"dependencies": {
"@aws-sdk/client-bedrock": "3.1078.0",
"@aws-sdk/client-bedrock-runtime": "3.1078.0",
"@aws-sdk/credential-provider-node": "3.972.61",
"@smithy/node-http-handler": "4.9.2",
"@smithy/shared-ini-file-loader": "4.6.5",
"@smithy/types": "4.15.1"
},
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*"
},
"openclaw": {
"extensions": [
"./index.ts"
],
"install": {
"npmSpec": "@openclaw/amazon-bedrock-provider",
"defaultChoice": "npm",
"minHostVersion": ">=2026.5.12-beta.1"
},
"compat": {
"pluginApi": ">=2026.6.11"
},
"build": {
"openclawVersion": "2026.6.11",
"bundledDist": false
},
"release": {
"publishToClawHub": true,
"publishToNpm": true
}
}
}

View File

@@ -0,0 +1,101 @@
// Amazon Bedrock tests cover provider policy api plugin behavior.
import { describe, expect, it } from "vitest";
import { resolveThinkingProfile } from "./provider-policy-api.js";
describe("amazon-bedrock provider-policy-api", () => {
it("exposes adaptive thinking for Bedrock Claude 4.6 before runtime registration", () => {
const profile = resolveThinkingProfile({
provider: "amazon-bedrock",
modelId: "amazon-bedrock/global.anthropic.claude-opus-4-6-v1",
});
expect(profile?.levels.map((level) => level.id)).toEqual([
"off",
"minimal",
"low",
"medium",
"high",
"adaptive",
"max",
]);
expect(profile?.defaultLevel).toBe("adaptive");
});
it("caps Bedrock Claude Sonnet 4.6 at high effort", () => {
const profile = resolveThinkingProfile({
provider: "amazon-bedrock",
modelId: "amazon-bedrock/global.anthropic.claude-sonnet-4-6",
});
expect(profile?.levels.map((level) => level.id)).toEqual([
"off",
"minimal",
"low",
"medium",
"high",
"adaptive",
]);
});
it("leaves Bedrock Claude Opus 4.8 thinking off by default with max effort available", () => {
const profile = resolveThinkingProfile({
provider: "amazon-bedrock",
modelId:
"arn:aws:bedrock:us-west-2:123456789012:inference-profile/us.anthropic.claude-opus-4-8",
});
expect(profile?.levels.map((level) => level.id)).toEqual([
"off",
"minimal",
"low",
"medium",
"high",
"xhigh",
"adaptive",
"max",
]);
expect(profile?.defaultLevel).toBe("off");
});
it("exposes max thinking for Bedrock Claude Opus 4.7 refs", () => {
expect(
resolveThinkingProfile({
provider: "amazon-bedrock",
modelId:
"arn:aws:bedrock:us-west-2:123456789012:inference-profile/us.anthropic.claude-opus-4-7",
})?.levels.map((level) => level.id),
).toEqual(["off", "minimal", "low", "medium", "high", "xhigh", "adaptive", "max"]);
});
it.each([
{
canonicalModelId: "claude-fable-5",
defaultLevel: "high",
preservesCatalogOptOut: true,
},
{
canonicalModelId: "claude-opus-4-8",
defaultLevel: "off",
preservesCatalogOptOut: false,
},
])(
"resolves $canonicalModelId deployment aliases from canonical metadata",
({ canonicalModelId, defaultLevel, preservesCatalogOptOut }) => {
const profile = resolveThinkingProfile({
provider: "amazon-bedrock",
modelId: "production-claude",
params: { canonicalModelId },
});
expect(profile?.defaultLevel).toBe(defaultLevel);
expect(profile?.levels.map((level) => level.id)).toContain("max");
expect(profile?.preserveWhenCatalogReasoningFalse === true).toBe(preservesCatalogOptOut);
},
);
it("ignores unrelated providers", () => {
expect(
resolveThinkingProfile({ provider: "anthropic", modelId: "claude-opus-4-6" }),
).toBeNull();
});
});

View File

@@ -0,0 +1,18 @@
/**
* Provider-policy API for Amazon Bedrock. Core asks this plugin for thinking
* profiles without importing provider registration or streaming code.
*/
import { normalizeProviderId } from "openclaw/plugin-sdk/provider-model-shared";
import { resolveBedrockClaudeThinkingProfile } from "./thinking-policy.js";
/** Resolve the Bedrock thinking profile for a provider/model pair. */
export function resolveThinkingProfile(params: {
provider: string;
modelId: string;
params?: Record<string, unknown>;
}) {
if (normalizeProviderId(params.provider) !== "amazon-bedrock") {
return null;
}
return resolveBedrockClaudeThinkingProfile(params.modelId, params.params);
}

View File

@@ -0,0 +1,718 @@
/**
* Synchronous Amazon Bedrock provider registration. It wires Bedrock streaming,
* model discovery, thinking policy, guardrails, and embedding integration.
*/
import type { StreamFn } from "openclaw/plugin-sdk/agent-core";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { registerApiProvider, streamSimple } from "openclaw/plugin-sdk/llm";
import { resolvePluginConfigObject } from "openclaw/plugin-sdk/plugin-config-runtime";
import type {
OpenClawPluginApi,
ProviderNormalizeResolvedModelContext,
} from "openclaw/plugin-sdk/plugin-entry";
import {
ANTHROPIC_BY_MODEL_REPLAY_HOOKS,
normalizeProviderId,
resolveClaudeFable5ModelIdentity,
resolveClaudeModelIdentity,
} from "openclaw/plugin-sdk/provider-model-shared";
import { streamWithPayloadPatch } from "openclaw/plugin-sdk/provider-stream-shared";
import { refreshAwsSharedConfigCacheForBedrock } from "./aws-credential-refresh.js";
import { supportsBedrockPromptCaching } from "./bedrock-options.js";
import { mergeImplicitBedrockProvider, resolveBedrockConfigApiKey } from "./discovery-shared.js";
import { bedrockMemoryEmbeddingProviderAdapter } from "./memory-embedding-adapter.js";
import { streamBedrock, streamSimpleBedrock } from "./stream.runtime.js";
import {
isLatestAdaptiveBedrockModelRef,
isOpus47OrNewerBedrockModelRef,
resolveBedrockNativeThinkingLevelMap,
resolveBedrockClaudeThinkingProfile,
supportsBedrockNativeMaxEffort,
} from "./thinking-policy.js";
type GuardrailConfig = {
guardrailIdentifier: string;
guardrailVersion: string;
streamProcessingMode?: "sync" | "async";
trace?: "enabled" | "disabled" | "enabled_full";
};
type AmazonBedrockPluginConfig = {
discovery?: {
enabled?: boolean;
region?: string;
providerFilter?: string[];
refreshInterval?: number;
defaultContextWindow?: number;
defaultMaxTokens?: number;
};
guardrail?: GuardrailConfig;
};
function normalizeBedrockResolvedModel({ modelId, model }: ProviderNormalizeResolvedModelContext) {
const thinkingLevelMap = resolveBedrockNativeThinkingLevelMap(modelId, model.params);
if (!thinkingLevelMap) {
return undefined;
}
const reasoning =
model.reasoning ||
resolveClaudeFable5ModelIdentity({ id: modelId, params: model.params }) !== undefined;
const current = model.thinkingLevelMap;
const currentEfforts = current as Record<string, string | null | undefined> | undefined;
if (
reasoning === model.reasoning &&
Object.entries(thinkingLevelMap).every(([level, effort]) => currentEfforts?.[level] === effort)
) {
return undefined;
}
return {
...model,
reasoning,
thinkingLevelMap: { ...thinkingLevelMap, ...current },
};
}
const BEDROCK_SERVICE_TIER_VALUES = ["flex", "priority", "default", "reserved"] as const;
type BedrockServiceTier = (typeof BEDROCK_SERVICE_TIER_VALUES)[number];
function isAnthropicBedrockModel(modelId: string): boolean {
const normalized = modelId.trim().toLowerCase();
if (normalized.includes("anthropic.claude") || normalized.includes("anthropic/claude")) {
return true;
}
if (
/^arn:aws(-cn|-us-gov)?:bedrock:/.test(normalized) &&
normalized.includes(":application-inference-profile/")
) {
const profileId = normalized.split(":application-inference-profile/")[1] ?? "";
return profileId.includes("claude");
}
return false;
}
function createBedrockNoCacheWrapper(baseStreamFn: StreamFn | undefined): StreamFn {
const underlying = baseStreamFn ?? streamSimple;
return (model, context, options) =>
underlying(model, context, {
...options,
cacheRetention: "none",
});
}
function isBedrockServiceTier(value: string): value is BedrockServiceTier {
return BEDROCK_SERVICE_TIER_VALUES.some((tier) => tier === value);
}
function resolveBedrockServiceTier(
extraParams: Record<string, unknown> | undefined,
warn: (message: string) => void,
): BedrockServiceTier | undefined {
const raw = extraParams?.serviceTier ?? extraParams?.service_tier;
if (typeof raw !== "string") {
return undefined;
}
const normalized = raw.trim().toLowerCase();
if (isBedrockServiceTier(normalized)) {
return normalized;
}
warn(`ignoring invalid Bedrock service_tier param: ${raw}`);
return undefined;
}
function createBedrockServiceTierWrapper(
underlying: StreamFn,
serviceTier: BedrockServiceTier,
): StreamFn {
return (model, context, options) => {
if (model.api !== "bedrock-converse-stream") {
return underlying(model, context, options);
}
return streamWithPayloadPatch(underlying, model, context, options, (payloadObj) => {
payloadObj.serviceTier ??= { type: serviceTier };
});
};
}
function createGuardrailWrapStreamFn(
innerWrapStreamFn: (ctx: {
modelId: string;
model?: { params?: Record<string, unknown> };
streamFn?: StreamFn;
}) => StreamFn | null | undefined,
guardrailConfig: GuardrailConfig,
): (ctx: {
modelId: string;
model?: { params?: Record<string, unknown> };
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;
});
};
};
}
function sharedRuntimeWouldInjectCachePoints(modelId: string): boolean {
return supportsBedrockPromptCaching(modelId);
}
/**
* Detect Bedrock application inference profile ARNs — these are the only IDs
* where model-name-based checks fail because the ARN is opaque.
* System-defined profiles (us., eu., global.) and base model IDs always
* contain the model name and are handled by the shared model runtime natively.
*/
const BEDROCK_APP_INFERENCE_PROFILE_RE =
/^arn:aws(-cn|-us-gov)?:bedrock:.*:application-inference-profile\//i;
function isBedrockAppInferenceProfile(modelId: string): boolean {
return BEDROCK_APP_INFERENCE_PROFILE_RE.test(modelId);
}
/**
* The shared runtime's `supportsPromptCaching` checks `model.id` for specific Claude
* model name patterns, which fails for application inference profile ARNs (opaque
* IDs that may not contain the model name). When OpenClaw's `isAnthropicBedrockModel`
* identifies the model but the shared runtime won't inject cache points, we do it via onPayload.
*
* Gated to application inference profile ARNs only — regular Claude model IDs and
* system-defined inference profiles (us.anthropic.claude-*) are left to the shared runtime.
*/
function needsCachePointInjection(modelId: string): boolean {
// Only target application inference profile ARNs.
if (!isBedrockAppInferenceProfile(modelId)) {
return false;
}
// If the shared runtime would already inject cache points, skip.
if (sharedRuntimeWouldInjectCachePoints(modelId)) {
return false;
}
// Check if OpenClaw identifies this as an Anthropic model via the ARN heuristic.
if (isAnthropicBedrockModel(modelId)) {
return true;
}
return false;
}
/**
* Extract the region from a Bedrock ARN.
* e.g. "arn:aws:bedrock:us-east-1:123:application-inference-profile/abc" → "us-east-1"
*/
function extractRegionFromArn(arn: string): string | undefined {
const parts = arn.split(":");
// ARN format: arn:partition:service:region:account:resource
return parts.length >= 4 && parts[3] ? parts[3] : undefined;
}
/**
* Check if a resolved foundation model ARN supports prompt caching using the
* same matcher OpenClaw uses for direct model IDs.
*/
function resolvedModelSupportsCaching(modelArn: string): boolean {
return supportsBedrockPromptCaching(modelArn);
}
/**
* Resolve the underlying foundation model for an application inference profile
* via GetInferenceProfile. Results are cached so we only call the API once per
* profile ARN. Returns traits needed for request shaping when the model id is
* otherwise opaque.
*
* Region is extracted from the profile ARN itself to avoid mismatches when
* the OpenClaw config region differs from the profile's home region.
*/
type BedrockAppProfileTraits = {
cacheEligible: boolean;
omitTemperature: boolean;
};
const appProfileTraitsCache = new Map<string, BedrockAppProfileTraits>();
type BedrockGetInferenceProfileResponse = {
models?: Array<{ modelArn?: string }>;
};
type BedrockControlPlane = {
getInferenceProfile: (input: {
inferenceProfileIdentifier: string;
}) => Promise<BedrockGetInferenceProfileResponse>;
};
async function createBedrockControlPlane(region: string | undefined): Promise<BedrockControlPlane> {
await refreshAwsSharedConfigCacheForBedrock();
const { BedrockClient, GetInferenceProfileCommand } = await import("@aws-sdk/client-bedrock");
const client = new BedrockClient(region ? { region } : {});
return {
getInferenceProfile: async (input) => await client.send(new GetInferenceProfileCommand(input)),
};
}
async function resolveAppProfileTraits(
modelId: string,
fallbackRegion: string | undefined,
): Promise<BedrockAppProfileTraits> {
const cached = appProfileTraitsCache.get(modelId);
if (cached) {
return cached;
}
try {
const region = extractRegionFromArn(modelId) ?? fallbackRegion;
const controlPlane = await createBedrockControlPlane(region);
const resp = await controlPlane.getInferenceProfile({ inferenceProfileIdentifier: modelId });
const models = resp.models ?? [];
const modelArns = models.map((m: { modelArn?: string }) => m.modelArn ?? "");
const traits = {
cacheEligible:
models.length > 0 && modelArns.every((modelArn) => resolvedModelSupportsCaching(modelArn)),
omitTemperature: modelArns.some(isOpus47OrNewerBedrockModelRef),
};
appProfileTraitsCache.set(modelId, traits);
return traits;
} catch {
// Transient failures (throttling, network, IAM) should not be cached —
// return the heuristic fallback but allow retry on the next request.
return {
cacheEligible: isAnthropicBedrockModel(modelId),
omitTemperature: isOpus47OrNewerBedrockModelRef(modelId),
};
}
}
type BedrockCachePoint = { cachePoint: { type: "default"; ttl?: string } };
type BedrockContentBlock = Record<string, unknown>;
type BedrockMessage = { role?: string; content?: BedrockContentBlock[] };
function hasCachePoint(blocks: BedrockContentBlock[] | undefined): boolean {
return blocks?.some((b) => b.cachePoint != null) === true;
}
function makeCachePoint(cacheRetention: string | undefined): BedrockCachePoint {
return {
cachePoint: {
type: "default",
...(cacheRetention === "long" ? { ttl: "1h" } : {}),
},
};
}
/**
* Inject Bedrock Converse cache points into the payload when the shared runtime skipped them
* because it didn't recognize the model ID (application inference profiles).
*/
function injectBedrockCachePoints(
payload: Record<string, unknown>,
cacheRetention: string | undefined,
): void {
if (!cacheRetention || cacheRetention === "none") {
return;
}
const point = makeCachePoint(cacheRetention);
// Inject into system prompt if missing.
const system = payload.system as BedrockContentBlock[] | undefined;
if (Array.isArray(system) && system.length > 0 && !hasCachePoint(system)) {
system.push(point);
}
// Inject into the last user message if missing.
// Bedrock Converse uses lowercase roles ("user" / "assistant").
const messages = payload.messages as BedrockMessage[] | undefined;
if (Array.isArray(messages) && messages.length > 0) {
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i];
if (msg.role === "user" && Array.isArray(msg.content)) {
if (!hasCachePoint(msg.content)) {
msg.content.push(point);
}
break;
}
}
}
}
function patchMaxThinkingEffort(payload: Record<string, unknown>): void {
const fieldsValue = payload.additionalModelRequestFields;
const fields =
fieldsValue && typeof fieldsValue === "object" && !Array.isArray(fieldsValue)
? (fieldsValue as Record<string, unknown>)
: {};
const outputConfigValue = fields.output_config;
const outputConfig =
outputConfigValue && typeof outputConfigValue === "object" && !Array.isArray(outputConfigValue)
? (outputConfigValue as Record<string, unknown>)
: {};
outputConfig.effort = "max";
fields.output_config = outputConfig;
payload.additionalModelRequestFields = fields;
}
/** Register Amazon Bedrock provider, discovery catalog, stream wrappers, and embeddings. */
export function registerAmazonBedrockPlugin(api: OpenClawPluginApi): void {
// Keep registration-local constants inside the function so partial module
// initialization during test bootstrap cannot trip TDZ reads.
const providerId = "amazon-bedrock";
// Match region from bedrock-runtime (Converse API) URLs.
// e.g. https://bedrock-runtime.us-east-1.amazonaws.com
const bedrockRegionRe = /bedrock-runtime\.([a-z0-9-]+)\.amazonaws\./;
const bedrockContextOverflowPatterns = [
/ValidationException.*(?:input is too long|max input token|input token.*exceed)/i,
/ValidationException.*(?:exceeds? the (?:maximum|max) (?:number of )?(?:input )?tokens)/i,
/ModelStreamErrorException.*(?:Input is too long|too many input tokens)/i,
] as const;
const deprecatedTemperatureValidationRe =
/ValidationException[\s\S]*(?:invalid_request_error[\s\S]*)?temperature[\s\S]*deprecated|ValidationException[\s\S]*deprecated[\s\S]*temperature/i;
const anthropicByModelReplayHooks = ANTHROPIC_BY_MODEL_REPLAY_HOOKS;
const startupPluginConfig = (api.pluginConfig ?? {}) as AmazonBedrockPluginConfig;
registerApiProvider(
{
api: "bedrock-converse-stream",
stream: streamBedrock,
streamSimple: streamSimpleBedrock,
},
`plugin:${providerId}`,
);
function resolveCurrentPluginConfig(
config: OpenClawConfig | undefined,
): AmazonBedrockPluginConfig | undefined {
const runtimePluginConfig = resolvePluginConfigObject(config, providerId);
return (
(runtimePluginConfig as AmazonBedrockPluginConfig | undefined) ??
(config ? undefined : startupPluginConfig)
);
}
api.registerMemoryEmbeddingProvider(bedrockMemoryEmbeddingProviderAdapter);
const baseWrapStreamFn = ({
modelId,
model,
streamFn,
}: {
modelId: string;
model?: { params?: Record<string, unknown> };
streamFn?: StreamFn;
}) => {
const modelRef = { id: modelId, params: model?.params };
if (
isAnthropicBedrockModel(modelId) ||
resolveClaudeModelIdentity(modelRef).startsWith("claude-")
) {
return streamFn;
}
// For app inference profiles with opaque IDs, don't force cacheRetention: "none"
// yet — we may resolve them as Claude later via GetInferenceProfile.
if (isBedrockAppInferenceProfile(modelId)) {
return streamFn;
}
return createBedrockNoCacheWrapper(streamFn);
};
function omitUnsupportedClaudeTemperature<TOptions extends object>(
modelRef: { id: string; params?: Record<string, unknown> },
options: TOptions,
): TOptions {
const canonicalModelId = resolveClaudeModelIdentity(modelRef);
const omitsTemperature =
isOpus47OrNewerBedrockModelRef(modelRef.id) ||
isOpus47OrNewerBedrockModelRef(canonicalModelId) ||
resolveClaudeFable5ModelIdentity(modelRef) !== undefined;
if (!omitsTemperature || !("temperature" in options)) {
return options;
}
const next = { ...options } as typeof options & { temperature?: unknown };
delete next.temperature;
return next;
}
function omitUnsupportedClaudePayloadTemperature(payload: Record<string, unknown>): void {
const inferenceConfig = payload.inferenceConfig;
if (!inferenceConfig || typeof inferenceConfig !== "object") {
return;
}
delete (inferenceConfig as Record<string, unknown>).temperature;
}
function withAwsCredentialRefreshOnPayload<TOptions extends object>(
options: TOptions,
): TOptions & { onPayload: (payload: unknown, payloadModel: unknown) => Promise<unknown> } {
const originalOnPayload = (options as { onPayload?: unknown }).onPayload as
| ((payload: unknown, model: unknown) => unknown)
| undefined;
return {
...options,
onPayload: async (payload: unknown, payloadModel: unknown) => {
await refreshAwsSharedConfigCacheForBedrock();
return originalOnPayload?.(payload, payloadModel);
},
};
}
function createAwsCredentialRefreshStreamWrapper(
streamFn: StreamFn | null | undefined,
): StreamFn | null | undefined {
if (!streamFn) {
return streamFn;
}
return (streamModel, context, options) =>
streamFn(streamModel, context, withAwsCredentialRefreshOnPayload(Object.assign({}, options)));
}
/** Extract the AWS region from a bedrock-runtime baseUrl. */
function extractRegionFromBaseUrl(baseUrl: string | undefined): string | undefined {
if (!baseUrl) {
return undefined;
}
return bedrockRegionRe.exec(baseUrl)?.[1];
}
/** Resolve the AWS region for Bedrock API calls from provider-specific baseUrl. */
function resolveBedrockRegion(
config: { models?: { providers?: Record<string, unknown> } } | undefined,
): string | undefined {
// Try provider-specific baseUrl first.
const providers = config?.models?.providers;
if (providers) {
const exact = (providers[providerId] as { baseUrl?: string } | undefined)?.baseUrl;
if (exact) {
const region = extractRegionFromBaseUrl(exact);
if (region) {
return region;
}
}
// Fall back to alias matches (e.g. "bedrock" instead of "amazon-bedrock").
for (const [key, value] of Object.entries(providers)) {
if (key === providerId || normalizeProviderId(key) !== providerId) {
continue;
}
const region = extractRegionFromBaseUrl((value as { baseUrl?: string }).baseUrl);
if (region) {
return region;
}
}
}
return undefined;
}
api.registerProvider({
id: providerId,
label: "Amazon Bedrock",
docsPath: "/providers/models",
auth: [],
catalog: {
order: "simple",
run: async (ctx) => {
const { resolveImplicitBedrockProvider } = await import("./discovery.js");
const currentPluginConfig = resolveCurrentPluginConfig(ctx.config);
const implicit = await resolveImplicitBedrockProvider({
pluginConfig: currentPluginConfig,
env: ctx.env,
});
if (!implicit) {
return null;
}
return {
provider: mergeImplicitBedrockProvider({
existing: ctx.config.models?.providers?.[providerId],
implicit,
}),
};
},
},
resolveConfigApiKey: ({ env }) => resolveBedrockConfigApiKey(env),
normalizeResolvedModel: normalizeBedrockResolvedModel,
...anthropicByModelReplayHooks,
wrapStreamFn: ({ modelId, config, model, streamFn, thinkingLevel, extraParams }) => {
const currentPluginConfig = resolveCurrentPluginConfig(config);
const currentGuardrail = currentPluginConfig?.guardrail;
const modelRef = { id: modelId, params: model?.params };
const fable5 = resolveClaudeFable5ModelIdentity(modelRef) !== undefined;
const canonicalModelId = resolveClaudeModelIdentity(modelRef);
const opus47OrNewer =
isOpus47OrNewerBedrockModelRef(modelId) || isOpus47OrNewerBedrockModelRef(canonicalModelId);
const supportsNativeMax = supportsBedrockNativeMaxEffort(modelId, model?.params);
let wrapped =
(currentGuardrail?.guardrailIdentifier && currentGuardrail?.guardrailVersion
? createGuardrailWrapStreamFn(
baseWrapStreamFn,
currentGuardrail,
)({
modelId,
model,
streamFn,
})
: baseWrapStreamFn({ modelId, model, streamFn })) ?? undefined;
const serviceTier = resolveBedrockServiceTier(extraParams, (message) =>
api.logger.warn(message),
);
if (serviceTier && wrapped) {
if (fable5 && serviceTier !== "default") {
api.logger.warn(`ignoring unsupported Fable 5 Bedrock service tier: ${serviceTier}`);
} else {
wrapped = createBedrockServiceTierWrapper(wrapped, serviceTier);
}
}
const region =
resolveBedrockRegion(config) ??
extractRegionFromBaseUrl(model?.baseUrl) ??
currentPluginConfig?.discovery?.region;
const mayNeedCacheInjection =
isBedrockAppInferenceProfile(modelId) && !sharedRuntimeWouldInjectCachePoints(modelId);
const shouldOmitTemperature =
opus47OrNewer || fable5 || isLatestAdaptiveBedrockModelRef(modelId, model?.params);
const shouldPatchMaxThinking = supportsNativeMax && thinkingLevel === "max";
const shouldPatchPayload = shouldOmitTemperature || shouldPatchMaxThinking;
// For known Anthropic models (heuristic match), enable injection immediately.
// For opaque profile IDs, we'll resolve via GetInferenceProfile on first call.
const heuristicMatch = needsCachePointInjection(modelId);
if (!region && !mayNeedCacheInjection && !shouldOmitTemperature && !shouldPatchMaxThinking) {
return createAwsCredentialRefreshStreamWrapper(wrapped);
}
const underlying = wrapped ?? streamFn;
if (!underlying) {
return wrapped;
}
return (streamModel, context, options) => {
const merged = omitUnsupportedClaudeTemperature(
modelRef,
Object.assign({}, options, region ? { region } : {}),
);
const originalOnPayload = merged.onPayload as
| ((payload: unknown, model: unknown) => unknown)
| undefined;
if (!mayNeedCacheInjection) {
return underlying(
streamModel,
context,
withAwsCredentialRefreshOnPayload({
...merged,
...(shouldPatchPayload
? {
onPayload: (payload: unknown, payloadModel: unknown) => {
if (payload && typeof payload === "object") {
const payloadRecord = payload as Record<string, unknown>;
if (shouldPatchMaxThinking) {
patchMaxThinkingEffort(payloadRecord);
}
if (shouldOmitTemperature) {
omitUnsupportedClaudePayloadTemperature(payloadRecord);
}
}
return originalOnPayload?.(payload, payloadModel);
},
}
: {}),
}),
);
}
// Use the cacheRetention from options if explicitly set.
// When undefined, default to "short" to match the shared runtime default.
// Note: if the user set cacheRetention: "none" but the opaque ARN wasn't
// recognized by resolveAnthropicCacheRetentionFamily, the value may have
// been dropped upstream. This is a known limitation — the proper fix is
// to also teach resolveAnthropicCacheRetentionFamily about opaque profiles
// (tracked separately). In practice, users with app inference profiles
// want caching enabled, so defaulting to "short" is the safer behavior.
const cacheRetention =
typeof merged.cacheRetention === "string" ? merged.cacheRetention : "short";
if (heuristicMatch) {
// Fast path: ARN heuristic already identified this as Claude, but the
// concrete target may still need profile traits for Opus 4.7 payloads.
const mayNeedTemperatureTrait = "temperature" in merged;
return underlying(
streamModel,
context,
withAwsCredentialRefreshOnPayload({
...merged,
onPayload: async (payload: unknown, payloadModel: unknown) => {
if (payload && typeof payload === "object") {
const payloadRecord = payload as Record<string, unknown>;
injectBedrockCachePoints(payloadRecord, cacheRetention);
if (shouldPatchMaxThinking) {
patchMaxThinkingEffort(payloadRecord);
}
if (shouldOmitTemperature) {
omitUnsupportedClaudePayloadTemperature(payloadRecord);
} else if (mayNeedTemperatureTrait) {
const traits = await resolveAppProfileTraits(modelId, region);
if (traits.omitTemperature) {
omitUnsupportedClaudePayloadTemperature(payloadRecord);
}
}
}
return originalOnPayload?.(payload, payloadModel);
},
}),
);
}
// Slow path: opaque profile ID — resolve underlying model via API (cached).
// onPayload supports async, so we await the resolution inline.
return underlying(
streamModel,
context,
withAwsCredentialRefreshOnPayload({
...merged,
onPayload: async (payload: unknown, payloadModel: unknown) => {
const traits = await resolveAppProfileTraits(modelId, region);
if (payload && typeof payload === "object") {
const payloadRecord = payload as Record<string, unknown>;
if (traits.cacheEligible) {
injectBedrockCachePoints(payloadRecord, cacheRetention);
}
if (shouldPatchMaxThinking) {
patchMaxThinkingEffort(payloadRecord);
}
if (traits.omitTemperature) {
omitUnsupportedClaudePayloadTemperature(payloadRecord);
}
}
return originalOnPayload?.(payload, payloadModel);
},
}),
);
};
},
matchesContextOverflowError: ({ errorMessage }) =>
bedrockContextOverflowPatterns.some((pattern) => pattern.test(errorMessage)),
classifyFailoverReason: ({ errorMessage }) => {
if (/ThrottlingException|Too many concurrent requests/i.test(errorMessage)) {
return "rate_limit";
}
if (/ModelNotReadyException/i.test(errorMessage)) {
return "overloaded";
}
if (deprecatedTemperatureValidationRe.test(errorMessage)) {
return "format";
}
return undefined;
},
resolveThinkingProfile: ({ modelId, params }) =>
resolveBedrockClaudeThinkingProfile(modelId, params),
});
}

View File

@@ -0,0 +1,22 @@
/**
* Lightweight Amazon Bedrock setup entry. It exposes auth detection and config
* migration hooks without loading runtime streaming or AWS discovery code.
*/
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import { migrateAmazonBedrockLegacyConfig } from "./config-api.js";
import { resolveBedrockConfigApiKey } from "./discovery-shared.js";
export default definePluginEntry({
id: "amazon-bedrock",
name: "Amazon Bedrock Setup",
description: "Lightweight Amazon Bedrock setup hooks",
register(api) {
api.registerProvider({
id: "amazon-bedrock",
label: "Amazon Bedrock",
auth: [],
resolveConfigApiKey: ({ env }) => resolveBedrockConfigApiKey(env),
});
api.registerConfigMigration((config) => migrateAmazonBedrockLegacyConfig(config));
},
});

View File

@@ -0,0 +1,565 @@
// Amazon Bedrock tests cover stream plugin behavior.
import { BedrockRuntimeClient, ConversationRole } from "@aws-sdk/client-bedrock-runtime";
import { onLlmRequestActivity } from "openclaw/plugin-sdk/provider-stream-shared";
import { afterEach, describe, expect, it, vi } from "vitest";
import { streamBedrock, streamSimpleBedrock, testing } from "./stream.runtime.js";
function bedrockModel(overrides: Record<string, unknown>) {
return {
api: "bedrock-converse-stream",
provider: "amazon-bedrock",
id: "amazon.nova-micro-v1:0",
name: "Nova Micro",
baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
maxTokens: 4096,
...overrides,
} as never;
}
function signedThinkingContext(modelId: string) {
const highSurrogate = String.fromCharCode(0xd83d);
return {
messages: [
{
role: "assistant",
api: "bedrock-converse-stream",
provider: "amazon-bedrock",
model: modelId,
content: [
{
type: "thinking",
thinking: `private${highSurrogate}reasoning`,
thinkingSignature: "sig-1",
},
],
},
],
} as never;
}
async function* streamEvents(events: unknown[]) {
for (const event of events) {
yield event;
}
}
afterEach(() => {
vi.restoreAllMocks();
});
describe("Bedrock reasoning replay", () => {
it("preserves signed reasoning for Claude profile descriptors", () => {
const modelId =
"arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/profile-abc";
const messages = testing.convertMessages(
signedThinkingContext(modelId),
bedrockModel({
id: modelId,
name: "Claude Sonnet application profile",
}),
"none",
);
expect(messages[0]?.content).toEqual([
{
reasoningContent: {
reasoningText: {
text: `private${String.fromCharCode(0xd83d)}reasoning`,
signature: "sig-1",
},
},
},
]);
});
it("replays signed reasoning as plain text for non-Claude models", () => {
const modelId = "amazon.nova-micro-v1:0";
const messages = testing.convertMessages(
signedThinkingContext(modelId),
bedrockModel({ id: modelId, name: "Nova Micro" }),
"none",
);
expect(messages[0]?.content).toEqual([{ text: "privatereasoning" }]);
});
it("preserves signature-only Fable reasoning blocks", () => {
const modelId = "anthropic.claude-fable-5";
const messages = testing.convertMessages(
{
messages: [
{
role: "assistant",
api: "bedrock-converse-stream",
provider: "amazon-bedrock",
model: modelId,
content: [
{
type: "thinking",
thinking: "",
thinkingSignature: " sig-fable ",
},
],
},
],
} as never,
bedrockModel({ id: modelId, name: "Claude Fable 5" }),
"none",
);
expect(messages[0]?.content).toEqual([
{
reasoningContent: {
reasoningText: {
text: "",
signature: " sig-fable ",
},
},
},
]);
});
it("drops synthetic reasoning placeholders from Claude replay", () => {
const modelId = "anthropic.claude-fable-5";
const messages = testing.convertMessages(
{
messages: [
{
role: "assistant",
api: "bedrock-converse-stream",
provider: "amazon-bedrock",
model: modelId,
content: [
{
type: "thinking",
thinking: "hidden compatibility reasoning",
thinkingSignature: "reasoning_content",
},
],
},
],
} as never,
bedrockModel({ id: modelId, name: "Claude Fable 5" }),
"none",
);
expect(messages).toEqual([]);
});
});
describe("Bedrock profile endpoint resolution", () => {
it("treats request profiles as configured profiles for standard endpoints", () => {
const endpoint = "https://bedrock-runtime.us-west-2.amazonaws.com";
expect(testing.hasConfiguredBedrockProfile({ profile: "prod-bedrock" })).toBe(true);
expect(
testing.shouldUseExplicitBedrockEndpoint(
endpoint,
undefined,
testing.hasConfiguredBedrockProfile({ profile: "prod-bedrock" }),
),
).toBe(false);
});
});
describe("Bedrock thinking effort mapping", () => {
it("does not force adaptive thinking for optional Claude models when callers omit reasoning", () => {
const model = bedrockModel({
id: "anthropic.claude-sonnet-4-6-v1:0",
name: "Claude Sonnet 4.6",
reasoning: true,
});
const options = testing.resolveSimpleBedrockOptions(model, {});
expect(options.reasoning).toBeUndefined();
expect(testing.buildAdditionalModelRequestFields(model, options)).toBeUndefined();
});
it("uses the model maxTokens cap for adaptive Claude thinking requests", () => {
const model = bedrockModel({
id: "us.anthropic.claude-opus-4-8",
name: "Claude Opus 4.8",
reasoning: true,
contextWindow: 1_000_000,
maxTokens: 128_000,
});
const options = testing.resolveSimpleBedrockOptions(model, { reasoning: "high" });
expect(options.maxTokens).toBe(128_000);
expect(options.reasoning).toBe("high");
expect(testing.buildAdditionalModelRequestFields(model, options)).toEqual({
thinking: { type: "adaptive", display: "summarized" },
output_config: { effort: "high" },
});
});
it.each([4096, 8192, 16_384])(
"does not turn fallback maxTokens %s into an adaptive cap",
(maxTokens) => {
const model = bedrockModel({
id: "us.anthropic.claude-opus-4-8",
name: "Claude Opus 4.8",
reasoning: true,
maxTokens,
});
const options = testing.resolveSimpleBedrockOptions(model, { reasoning: "high" });
expect(options.maxTokens).toBeUndefined();
expect(options.reasoning).toBe("high");
},
);
it("preserves explicit maxTokens caps for adaptive Claude thinking requests", () => {
const model = bedrockModel({
id: "us.anthropic.claude-opus-4-8",
name: "Claude Opus 4.8",
reasoning: true,
contextWindow: 1_000_000,
maxTokens: 128_000,
});
const options = testing.resolveSimpleBedrockOptions(model, {
reasoning: "high",
maxTokens: 32_000,
});
expect(options.maxTokens).toBe(32_000);
});
it("forces adaptive thinking for Bedrock Mythos Preview when callers omit reasoning", () => {
const model = bedrockModel({
id: "us.anthropic.claude-mythos-preview",
name: "US Claude Mythos Preview",
reasoning: true,
contextWindow: 1_000_000,
maxTokens: 128_000,
});
const options = testing.resolveSimpleBedrockOptions(model, {});
expect(options.reasoning).toBe("high");
expect(options.maxTokens).toBe(128_000);
expect(testing.buildAdditionalModelRequestFields(model, options)).toEqual({
thinking: { type: "adaptive", display: "summarized" },
output_config: { effort: "high" },
});
});
it("clamps max effort for Claude models without native max support", () => {
expect(
testing.mapThinkingLevelToEffort(
bedrockModel({
id: "anthropic.claude-sonnet-4-6-v1:0",
name: "Claude Sonnet 4.6",
}),
"max",
),
).toBe("high");
});
it("caps unsupported xhigh effort at high for Claude Opus 4.6", () => {
expect(
testing.mapThinkingLevelToEffort(
bedrockModel({
id: "anthropic.claude-opus-4-6-v1:0",
name: "Claude Opus 4.6",
}),
"xhigh",
),
).toBe("high");
});
it("preserves max effort for Claude Opus 4.8", () => {
expect(
testing.mapThinkingLevelToEffort(
bedrockModel({
id: "anthropic.claude-opus-4.8-v1:0",
name: "Claude Opus 4.8",
}),
"max",
),
).toBe("max");
});
it("uses canonical Claude policy for deployment aliases", () => {
expect(
testing.mapThinkingLevelToEffort(
bedrockModel({
id: "production-claude",
name: "Production Claude",
params: { canonicalModelId: "claude-opus-4-8" },
}),
"max",
),
).toBe("max");
});
it("preserves adaptive effort for opaque profiles with descriptive Claude names", () => {
expect(
testing.mapThinkingLevelToEffort(
bedrockModel({
id: "arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/profile-abc",
name: "Claude Production Opus 4.8",
}),
"xhigh",
),
).toBe("xhigh");
});
});
describe("Bedrock Fable contract", () => {
function fableModel() {
return bedrockModel({
id: "production-fable",
name: "Production deployment",
reasoning: false,
params: { canonicalModelId: "claude-fable-5" },
contextWindow: 1_000_000,
maxTokens: 128_000,
});
}
function context() {
return {
messages: [{ role: "user", content: "Reply briefly.", timestamp: 0 }],
tools: [
{
name: "lookup",
description: "Lookup",
parameters: { type: "object", properties: {} },
},
],
} as never;
}
it("uses the model maxTokens cap for simple Fable options", () => {
const options = testing.resolveSimpleBedrockOptions(fableModel(), {});
expect(options).toMatchObject({
maxTokens: 128_000,
reasoning: "high",
});
});
it("sends always-adaptive high effort without unsupported request controls", async () => {
const send = vi.spyOn(BedrockRuntimeClient.prototype, "send").mockResolvedValue({
$metadata: { httpStatusCode: 200 },
stream: streamEvents([
{ messageStart: { role: ConversationRole.ASSISTANT } },
{ messageStop: { stopReason: "end_turn" } },
]),
} as never);
const stream = streamBedrock(fableModel(), context(), {
reasoning: "high",
temperature: 0.2,
toolChoice: "any",
});
await stream.result();
const command = send.mock.calls[0]?.[0] as { input?: Record<string, unknown> };
expect(command.input).toMatchObject({
modelId: "production-fable",
inferenceConfig: {},
messages: [
{
role: "user",
content: [{ text: "Reply briefly." }, { cachePoint: { type: "default" } }],
},
],
toolConfig: { toolChoice: { auto: {} } },
additionalModelRequestFields: {
thinking: { type: "adaptive", display: "summarized" },
output_config: { effort: "high" },
},
additionalModelResponseFieldPaths: ["/stop_details"],
});
});
it("preserves explicit tool disabling", async () => {
const send = vi.spyOn(BedrockRuntimeClient.prototype, "send").mockResolvedValue({
$metadata: { httpStatusCode: 200 },
stream: streamEvents([
{ messageStart: { role: ConversationRole.ASSISTANT } },
{ messageStop: { stopReason: "end_turn" } },
]),
} as never);
const stream = streamBedrock(fableModel(), context(), {
reasoning: "high",
toolChoice: "none",
});
await stream.result();
const command = send.mock.calls[0]?.[0] as { input?: Record<string, unknown> };
expect(command.input?.toolConfig).toBeUndefined();
});
it("quarantines partial output when Fable returns a terminal refusal", async () => {
vi.spyOn(BedrockRuntimeClient.prototype, "send").mockResolvedValue({
$metadata: { httpStatusCode: 200 },
stream: streamEvents([
{
contentBlockDelta: {
contentBlockIndex: 0,
delta: { text: "discard this partial output" },
},
},
{
messageStop: {
stopReason: "refusal",
additionalModelResponseFields: {
stop_details: {
category: "cyber",
explanation: "This request is not allowed.",
},
},
},
},
]),
} as never);
const stream = streamSimpleBedrock(fableModel(), context());
const eventTypes: string[] = [];
for await (const event of stream) {
eventTypes.push(event.type);
}
const result = await stream.result();
expect(eventTypes).toEqual(["error"]);
expect(result.content).toEqual([]);
expect(result.errorMessage).toBe(
"Anthropic refusal (category: cyber): This request is not allowed.",
);
expect(result.diagnostics).toEqual([
expect.objectContaining({
type: "provider_refusal",
details: {
provider: "amazon-bedrock",
category: "cyber",
explanation: "This request is not allowed.",
},
}),
]);
});
it("discards partial output when the Fable stream ends without messageStop", async () => {
vi.spyOn(BedrockRuntimeClient.prototype, "send").mockResolvedValue({
$metadata: { httpStatusCode: 200 },
stream: streamEvents([
{ messageStart: { role: ConversationRole.ASSISTANT } },
{
contentBlockDelta: {
contentBlockIndex: 0,
delta: { text: "unsafe partial output" },
},
},
]),
} as never);
const stream = streamSimpleBedrock(fableModel(), context());
const eventTypes: string[] = [];
for await (const event of stream) {
eventTypes.push(event.type);
}
const result = await stream.result();
expect(eventTypes).toEqual(["error"]);
expect(result.content).toEqual([]);
expect(result.errorMessage).toContain("ended before messageStop");
});
it("reports activity while Fable events are buffered", async () => {
vi.spyOn(BedrockRuntimeClient.prototype, "send").mockResolvedValue({
$metadata: { httpStatusCode: 200 },
stream: streamEvents([
{ messageStart: { role: ConversationRole.ASSISTANT } },
{
contentBlockDelta: {
contentBlockIndex: 0,
delta: { text: "buffered output" },
},
},
{ messageStop: { stopReason: "end_turn" } },
]),
} as never);
const controller = new AbortController();
let activityCount = 0;
const unsubscribe = onLlmRequestActivity(controller.signal, () => {
activityCount += 1;
});
try {
const stream = streamSimpleBedrock(fableModel(), context(), {
signal: controller.signal,
});
await stream.result();
} finally {
unsubscribe();
}
expect(activityCount).toBeGreaterThan(0);
});
});
describe("Bedrock canonical Claude aliases", () => {
it.each([
{
canonicalModelId: "claude-opus-4-8",
reasoning: "xhigh" as const,
thinkingLevelMap: { xhigh: "xhigh" as const, max: "max" as const },
expectedEffort: "xhigh",
},
{
canonicalModelId: "claude-opus-4-6",
reasoning: "max" as const,
thinkingLevelMap: { xhigh: null, max: "max" as const },
expectedEffort: "max",
},
{
canonicalModelId: "claude-opus-4-6",
reasoning: "max" as const,
thinkingLevelMap: { xhigh: null, max: null },
expectedEffort: "high",
},
])(
"uses adaptive thinking and omits temperature for $canonicalModelId aliases",
async ({ canonicalModelId, reasoning, thinkingLevelMap, expectedEffort }) => {
const send = vi.spyOn(BedrockRuntimeClient.prototype, "send").mockResolvedValue({
$metadata: { httpStatusCode: 200 },
stream: streamEvents([
{ messageStart: { role: ConversationRole.ASSISTANT } },
{ messageStop: { stopReason: "end_turn" } },
]),
} as never);
const model = bedrockModel({
id: "production-claude",
name: "Production Claude",
reasoning: false,
params: { canonicalModelId },
thinkingLevelMap,
});
await streamSimpleBedrock(
model,
{ messages: [{ role: "user", content: "Reply briefly.", timestamp: 0 }] } as never,
{
reasoning,
temperature: 0.2,
},
).result();
const command = send.mock.calls[0]?.[0] as { input?: Record<string, unknown> };
expect(command.input).toMatchObject({
modelId: "production-claude",
inferenceConfig: {},
additionalModelRequestFields: {
thinking: { type: "adaptive", display: "summarized" },
output_config: { effort: expectedEffort },
},
});
},
);
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,147 @@
/**
* Thinking-level policy for Claude models on Amazon Bedrock. It maps Bedrock
* model ids to the provider SDK thinking levels that are actually supported.
*/
import type {
ProviderRuntimeModel,
ProviderThinkingProfile,
} from "openclaw/plugin-sdk/plugin-entry";
import {
resolveClaudeFable5ModelIdentity,
resolveClaudeModelIdentity,
} from "openclaw/plugin-sdk/provider-model-shared";
const BASE_CLAUDE_THINKING_LEVELS = [
{ id: "off" },
{ id: "minimal" },
{ id: "low" },
{ id: "medium" },
{ id: "high" },
] as const satisfies ProviderThinkingProfile["levels"];
function isOpus48BedrockModelRef(modelRef: string): boolean {
return /(?:^|[/.:])(?:(?:us|eu|ap|apac|au|jp|global)\.)?(?:anthropic\.)?claude-opus-4[.-]8(?:$|[-.:/])/i.test(
modelRef,
);
}
function isOpus46BedrockModelRef(modelRef: string): boolean {
return /(?:^|[/.:])(?:(?:us|eu|ap|apac|au|jp|global)\.)?(?:anthropic\.)?claude-opus-4[.-]6(?:$|[-.:/])/i.test(
modelRef,
);
}
/** Return whether a Bedrock model ref names Claude Opus 4.7. */
export function isOpus47BedrockModelRef(modelRef: string): boolean {
return /(?:^|[/.:])(?:(?:us|eu|ap|apac|au|jp|global)\.)?(?:anthropic\.)?claude-opus-4[.-]7(?:$|[-.:/])/i.test(
modelRef,
);
}
/** Return whether a Bedrock model ref names Claude Opus 4.7 or newer. */
export function isOpus47OrNewerBedrockModelRef(modelRef: string): boolean {
return isOpus47BedrockModelRef(modelRef) || isOpus48BedrockModelRef(modelRef);
}
function isMythosPreviewBedrockModelRef(modelRef: string): boolean {
return /(?:^|[/.:])(?:(?:us|eu|ap|apac|au|jp|global)\.)?(?:anthropic\.)?claude-mythos-preview(?:$|[-.:/])/i.test(
modelRef,
);
}
/** Return whether a Bedrock Claude ref needs latest adaptive-thinking request shaping. */
export function isLatestAdaptiveBedrockModelRef(
modelId: string,
params?: Record<string, unknown>,
): boolean {
const modelRef = { id: modelId, params };
const canonicalModelId = resolveClaudeModelIdentity(modelRef);
return (
resolveClaudeFable5ModelIdentity(modelRef) !== undefined ||
[modelId, canonicalModelId].some(
(candidate) =>
isOpus47OrNewerBedrockModelRef(candidate) || isMythosPreviewBedrockModelRef(candidate),
)
);
}
/** Return whether a Bedrock Claude ref supports max effort. */
export function supportsBedrockNativeMaxEffort(
modelId: string,
params?: Record<string, unknown>,
): boolean {
if (resolveClaudeFable5ModelIdentity({ id: modelId, params })) {
return true;
}
const canonicalModelId = resolveClaudeModelIdentity({ id: modelId, params });
return [modelId, canonicalModelId].some(
(modelRef) => isOpus46BedrockModelRef(modelRef) || isOpus47OrNewerBedrockModelRef(modelRef),
);
}
/** Resolve route-specific native effort mappings for Bedrock Claude models. */
export function resolveBedrockNativeThinkingLevelMap(
modelId: string,
params?: Record<string, unknown>,
): ProviderRuntimeModel["thinkingLevelMap"] | undefined {
const modelRef = { id: modelId, params };
if (resolveClaudeFable5ModelIdentity(modelRef)) {
return { off: "low", minimal: "low", xhigh: "xhigh", max: "max" };
}
if (!supportsBedrockNativeMaxEffort(modelId, params)) {
return undefined;
}
const canonicalModelId = resolveClaudeModelIdentity(modelRef);
return {
xhigh: [modelId, canonicalModelId].some(isOpus47OrNewerBedrockModelRef) ? "xhigh" : null,
max: "max",
};
}
/** Resolve supported Claude thinking levels for a Bedrock model id. */
export function resolveBedrockClaudeThinkingProfile(
modelId: string,
params?: Record<string, unknown>,
): ProviderThinkingProfile {
const trimmed = modelId.trim();
const canonicalModelId = resolveClaudeModelIdentity({ id: trimmed, params });
const modelRefs = [trimmed, canonicalModelId];
if (resolveClaudeFable5ModelIdentity({ id: trimmed, params })) {
return {
levels: [...BASE_CLAUDE_THINKING_LEVELS, { id: "xhigh" }, { id: "adaptive" }, { id: "max" }],
defaultLevel: "high",
preserveWhenCatalogReasoningFalse: true,
};
}
if (modelRefs.some(isOpus48BedrockModelRef)) {
return {
levels: [...BASE_CLAUDE_THINKING_LEVELS, { id: "xhigh" }, { id: "adaptive" }, { id: "max" }],
defaultLevel: "off",
};
}
if (modelRefs.some(isOpus47BedrockModelRef)) {
return {
levels: [...BASE_CLAUDE_THINKING_LEVELS, { id: "xhigh" }, { id: "adaptive" }, { id: "max" }],
defaultLevel: "off",
};
}
if (modelRefs.some(isOpus46BedrockModelRef)) {
return {
levels: [...BASE_CLAUDE_THINKING_LEVELS, { id: "adaptive" }, { id: "max" }],
defaultLevel: "adaptive",
};
}
if (modelRefs.some(isMythosPreviewBedrockModelRef)) {
return {
levels: [...BASE_CLAUDE_THINKING_LEVELS, { id: "adaptive" }],
defaultLevel: "adaptive",
};
}
if (modelRefs.some((modelRef) => /claude-sonnet-4(?:\.|-)6(?:$|[-.])/i.test(modelRef))) {
return {
levels: [...BASE_CLAUDE_THINKING_LEVELS, { id: "adaptive" }],
defaultLevel: "adaptive",
};
}
return { levels: BASE_CLAUDE_THINKING_LEVELS };
}

View File

@@ -0,0 +1,16 @@
{
"extends": "../tsconfig.package-boundary.base.json",
"compilerOptions": {
"rootDir": "."
},
"include": ["./*.ts", "./src/**/*.ts"],
"exclude": [
"./**/*.test.ts",
"./dist/**",
"./node_modules/**",
"./src/test-support/**",
"./src/**/*test-helpers.ts",
"./src/**/*test-harness.ts",
"./src/**/*test-support.ts"
]
}