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
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:
11
extensions/amazon-bedrock/README.md
Normal file
11
extensions/amazon-bedrock/README.md
Normal 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.
|
||||
10
extensions/amazon-bedrock/api.ts
Normal file
10
extensions/amazon-bedrock/api.ts
Normal 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";
|
||||
34
extensions/amazon-bedrock/aws-credential-refresh.ts
Normal file
34
extensions/amazon-bedrock/aws-credential-refresh.ts
Normal 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 });
|
||||
}
|
||||
54
extensions/amazon-bedrock/bedrock-options.ts
Normal file
54
extensions/amazon-bedrock/bedrock-options.ts
Normal 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;
|
||||
}
|
||||
5
extensions/amazon-bedrock/config-api.ts
Normal file
5
extensions/amazon-bedrock/config-api.ts
Normal 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";
|
||||
82
extensions/amazon-bedrock/config-compat.test.ts
Normal file
82
extensions/amazon-bedrock/config-compat.test.ts
Normal 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).",
|
||||
]);
|
||||
});
|
||||
});
|
||||
112
extensions/amazon-bedrock/config-compat.ts
Normal file
112
extensions/amazon-bedrock/config-compat.ts
Normal 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 };
|
||||
}
|
||||
34
extensions/amazon-bedrock/discovery-shared.ts
Normal file
34
extensions/amazon-bedrock/discovery-shared.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
772
extensions/amazon-bedrock/discovery.test.ts
Normal file
772
extensions/amazon-bedrock/discovery.test.ts
Normal 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"],
|
||||
});
|
||||
});
|
||||
});
|
||||
693
extensions/amazon-bedrock/discovery.ts
Normal file
693
extensions/amazon-bedrock/discovery.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
154
extensions/amazon-bedrock/embedding-provider.test.ts
Normal file
154
extensions/amazon-bedrock/embedding-provider.test.ts
Normal 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
479
extensions/amazon-bedrock/embedding-provider.ts
Normal file
479
extensions/amazon-bedrock/embedding-provider.ts
Normal 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 };
|
||||
1581
extensions/amazon-bedrock/index.test.ts
Normal file
1581
extensions/amazon-bedrock/index.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
15
extensions/amazon-bedrock/index.ts
Normal file
15
extensions/amazon-bedrock/index.ts
Normal 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);
|
||||
},
|
||||
});
|
||||
57
extensions/amazon-bedrock/lazy-import.test.ts
Normal file
57
extensions/amazon-bedrock/lazy-import.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
106
extensions/amazon-bedrock/memory-embedding-adapter.test.ts
Normal file
106
extensions/amazon-bedrock/memory-embedding-adapter.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
52
extensions/amazon-bedrock/memory-embedding-adapter.ts
Normal file
52
extensions/amazon-bedrock/memory-embedding-adapter.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
470
extensions/amazon-bedrock/npm-shrinkwrap.json
generated
Normal file
470
extensions/amazon-bedrock/npm-shrinkwrap.json
generated
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
82
extensions/amazon-bedrock/openclaw.plugin.json
Normal file
82
extensions/amazon-bedrock/openclaw.plugin.json
Normal 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."
|
||||
}
|
||||
}
|
||||
}
|
||||
42
extensions/amazon-bedrock/package.json
Normal file
42
extensions/amazon-bedrock/package.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
101
extensions/amazon-bedrock/provider-policy-api.test.ts
Normal file
101
extensions/amazon-bedrock/provider-policy-api.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
18
extensions/amazon-bedrock/provider-policy-api.ts
Normal file
18
extensions/amazon-bedrock/provider-policy-api.ts
Normal 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);
|
||||
}
|
||||
718
extensions/amazon-bedrock/register.sync.runtime.ts
Normal file
718
extensions/amazon-bedrock/register.sync.runtime.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
22
extensions/amazon-bedrock/setup-api.ts
Normal file
22
extensions/amazon-bedrock/setup-api.ts
Normal 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));
|
||||
},
|
||||
});
|
||||
565
extensions/amazon-bedrock/stream.runtime.test.ts
Normal file
565
extensions/amazon-bedrock/stream.runtime.test.ts
Normal 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 },
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
1131
extensions/amazon-bedrock/stream.runtime.ts
Normal file
1131
extensions/amazon-bedrock/stream.runtime.ts
Normal file
File diff suppressed because it is too large
Load Diff
147
extensions/amazon-bedrock/thinking-policy.ts
Normal file
147
extensions/amazon-bedrock/thinking-policy.ts
Normal 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 };
|
||||
}
|
||||
16
extensions/amazon-bedrock/tsconfig.json
Normal file
16
extensions/amazon-bedrock/tsconfig.json
Normal 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"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user