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

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

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

View File

@@ -0,0 +1,11 @@
# OpenClaw Amazon Bedrock Mantle Provider
Official OpenClaw provider plugin for routing Amazon Bedrock Mantle models through OpenAI-compatible provider flows.
Install from OpenClaw:
```bash
openclaw plugin add @openclaw/amazon-bedrock-mantle-provider
```
Use this plugin when your Bedrock deployment exposes Mantle-compatible model routing and you want OpenClaw agents to address those models through the Bedrock Mantle provider.

View File

@@ -0,0 +1,16 @@
/**
* Public Amazon Bedrock Mantle API barrel for discovery and bearer-token
* helpers shared by config, runtime, and tests.
*/
export {
discoverMantleModels,
generateBearerTokenFromIam,
getCachedIamToken,
MANTLE_IAM_TOKEN_MARKER,
mergeImplicitMantleProvider,
resetIamTokenCacheForTest,
resetMantleDiscoveryCacheForTest,
resolveImplicitMantleProvider,
resolveMantleBearerToken,
resolveMantleRuntimeBearerToken,
} from "./discovery.js";

View File

@@ -0,0 +1,693 @@
// Amazon Bedrock Mantle tests cover discovery plugin behavior.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const {
discoverMantleModels,
generateBearerTokenFromIam,
getCachedIamToken,
MANTLE_IAM_TOKEN_MARKER,
mergeImplicitMantleProvider,
resetIamTokenCacheForTest,
resetMantleDiscoveryCacheForTest,
resolveImplicitMantleProvider,
resolveMantleBearerToken,
resolveMantleRuntimeBearerToken,
} = await import("./api.js");
function createTokenProviderFactory(tokenProvider: () => Promise<string>) {
return vi.fn(() => tokenProvider);
}
type MockWithCalls = {
mock: { calls: unknown[][] };
};
function argAt(mock: MockWithCalls, callIndex: number, argIndex: number): unknown {
const call = mock.mock.calls[callIndex];
if (!call) {
throw new Error(`expected call ${callIndex}`);
}
if (!(argIndex in call)) {
throw new Error(`expected call ${callIndex} argument ${argIndex}`);
}
return call[argIndex];
}
function objectArgAt(
mock: MockWithCalls,
callIndex: number,
argIndex: number,
): Record<string, unknown> {
const value = argAt(mock, callIndex, argIndex);
if (value === undefined || value === null || typeof value !== "object" || Array.isArray(value)) {
throw new Error(`expected call ${callIndex} argument ${argIndex} to be an object`);
}
return value as Record<string, unknown>;
}
function stringArgAt(mock: MockWithCalls, callIndex: number, argIndex: number): string {
const value = argAt(mock, callIndex, argIndex);
if (typeof value !== "string") {
throw new Error(`expected call ${callIndex} argument ${argIndex} to be a string`);
}
return value;
}
function recordField(value: unknown, field: string): Record<string, unknown> {
if (value === undefined || value === null || typeof value !== "object" || Array.isArray(value)) {
throw new Error(`expected ${field} to be an object`);
}
return value as Record<string, unknown>;
}
describe("bedrock mantle discovery", () => {
const originalEnv = process.env;
beforeEach(() => {
process.env = { ...originalEnv };
vi.restoreAllMocks();
resetMantleDiscoveryCacheForTest();
resetIamTokenCacheForTest();
});
afterEach(() => {
vi.restoreAllMocks();
resetMantleDiscoveryCacheForTest();
resetIamTokenCacheForTest();
process.env = originalEnv;
});
// ---------------------------------------------------------------------------
// Bearer token resolution
// ---------------------------------------------------------------------------
it("resolves bearer token from AWS_BEARER_TOKEN_BEDROCK", () => {
expect(
resolveMantleBearerToken({
AWS_BEARER_TOKEN_BEDROCK: "bedrock-api-key-abc123", // pragma: allowlist secret
} as NodeJS.ProcessEnv),
).toBe("bedrock-api-key-abc123");
});
it("returns undefined when no bearer token env var is set", () => {
expect(resolveMantleBearerToken({} as NodeJS.ProcessEnv)).toBeUndefined();
});
it("trims whitespace from bearer token", () => {
expect(
resolveMantleBearerToken({
AWS_BEARER_TOKEN_BEDROCK: " my-token ", // pragma: allowlist secret
} as NodeJS.ProcessEnv),
).toBe("my-token");
});
// ---------------------------------------------------------------------------
// IAM token generation
// ---------------------------------------------------------------------------
it("generates token from IAM credentials when token generation succeeds", async () => {
const tokenProvider = vi.fn(async () => "bedrock-api-key-generated"); // pragma: allowlist secret
const tokenProviderFactory = createTokenProviderFactory(tokenProvider);
const token = await generateBearerTokenFromIam({
region: "us-east-1",
tokenProviderFactory,
});
expect(token).toBe("bedrock-api-key-generated");
expect(tokenProviderFactory).toHaveBeenCalledWith({
region: "us-east-1",
expiresInSeconds: 7200,
});
expect(tokenProvider).toHaveBeenCalledTimes(1);
});
it("caches generated IAM tokens within TTL", async () => {
const tokenProvider = vi.fn(async () => "bedrock-api-key-cached"); // pragma: allowlist secret
const tokenProviderFactory = createTokenProviderFactory(tokenProvider);
let now = 1000;
const t1 = await generateBearerTokenFromIam({
region: "us-east-1",
now: () => now,
tokenProviderFactory,
});
now += 1800_000; // 30 min — within 2hr cache TTL
const t2 = await generateBearerTokenFromIam({
region: "us-east-1",
now: () => now,
tokenProviderFactory,
});
expect(t1).toEqual(t2);
expect(tokenProvider).toHaveBeenCalledTimes(1);
});
it("does not reuse an IAM token across regions", async () => {
const tokenProvider = vi
.fn<() => Promise<string>>()
.mockResolvedValueOnce("bedrock-api-key-east") // pragma: allowlist secret
.mockResolvedValueOnce("bedrock-api-key-west"); // pragma: allowlist secret
const tokenProviderFactory = createTokenProviderFactory(tokenProvider);
const east = await generateBearerTokenFromIam({
region: "us-east-1",
now: () => 1000,
tokenProviderFactory,
});
const west = await generateBearerTokenFromIam({
region: "us-west-2",
now: () => 2000,
tokenProviderFactory,
});
expect(east).toBe("bedrock-api-key-east");
expect(west).toBe("bedrock-api-key-west");
expect(tokenProviderFactory).toHaveBeenNthCalledWith(1, {
region: "us-east-1",
expiresInSeconds: 7200,
});
expect(tokenProviderFactory).toHaveBeenNthCalledWith(2, {
region: "us-west-2",
expiresInSeconds: 7200,
});
expect(tokenProvider).toHaveBeenCalledTimes(2);
});
it("returns undefined when IAM token generation fails", async () => {
const tokenProviderFactory = vi.fn(() => {
throw new Error("no credentials");
});
await expect(
generateBearerTokenFromIam({ region: "us-east-1", tokenProviderFactory }),
).resolves.toBeUndefined();
});
it("skips IAM token generation when plugin discovery is disabled", async () => {
const tokenProviderFactory = vi.fn(() => {
throw new Error("disabled discovery should not generate a token");
});
await expect(
resolveImplicitMantleProvider({
env: { AWS_REGION: "us-east-1" } as NodeJS.ProcessEnv,
pluginConfig: { discovery: { enabled: false } },
tokenProviderFactory,
}),
).resolves.toBeNull();
expect(tokenProviderFactory).not.toHaveBeenCalled();
});
it("getCachedIamToken returns cached token when valid", async () => {
const tokenProvider = vi.fn(async () => "bedrock-cached-token"); // pragma: allowlist secret
const tokenProviderFactory = createTokenProviderFactory(tokenProvider);
// Generate a token to populate the cache
await generateBearerTokenFromIam({ region: "us-east-1", tokenProviderFactory });
// Sync read should return the cached token
expect(getCachedIamToken("us-east-1")).toBe("bedrock-cached-token");
});
it("getCachedIamToken returns undefined when cache is empty", () => {
expect(getCachedIamToken("us-east-1")).toBeUndefined();
});
it("getCachedIamToken returns undefined when cache is expired", async () => {
const tokenProvider = vi.fn(async () => "bedrock-expired-token"); // pragma: allowlist secret
const tokenProviderFactory = createTokenProviderFactory(tokenProvider);
// Generate with a time far in the past so it's already expired
await generateBearerTokenFromIam({
region: "us-east-1",
now: () => 1000,
tokenProviderFactory,
});
// The cache entry exists but expiresAt is 1000 + 3600000 = 3601000
// Current Date.now() is way past that, so it should be expired
expect(getCachedIamToken("us-east-1")).toBeUndefined();
});
it("does not cache generated IAM tokens when ttl expiry overflows", async () => {
const tokenProvider = vi
.fn<() => Promise<string>>()
.mockResolvedValueOnce("bedrock-overflow-token-1") // pragma: allowlist secret
.mockResolvedValueOnce("bedrock-overflow-token-2"); // pragma: allowlist secret
const tokenProviderFactory = createTokenProviderFactory(tokenProvider);
await expect(
generateBearerTokenFromIam({
region: "us-east-1",
now: () => 8_640_000_000_000_000,
tokenProviderFactory,
}),
).resolves.toBe("bedrock-overflow-token-1");
expect(getCachedIamToken("us-east-1")).toBeUndefined();
await expect(
generateBearerTokenFromIam({
region: "us-east-1",
now: () => 8_640_000_000_000_000,
tokenProviderFactory,
}),
).resolves.toBe("bedrock-overflow-token-2");
expect(tokenProvider).toHaveBeenCalledTimes(2);
});
// ---------------------------------------------------------------------------
// Model discovery
// ---------------------------------------------------------------------------
it("discovers models from Mantle /v1/models endpoint sorted by id", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
data: [
{ id: "openai.gpt-oss-120b", object: "model", owned_by: "openai" },
{ id: "anthropic.claude-sonnet-4-6", object: "model", owned_by: "anthropic" },
{ id: "mistral.devstral-2-123b", object: "model", owned_by: "mistral" },
],
}),
});
const models = await discoverMantleModels({
region: "us-east-1",
bearerToken: "test-token",
fetchFn: mockFetch as unknown as typeof fetch,
});
expect(models).toHaveLength(3);
// Models should be sorted alphabetically by id
expect(models[0]?.id).toBe("anthropic.claude-sonnet-4-6");
expect(models[0]?.name).toBe("anthropic.claude-sonnet-4-6");
expect(models[0]?.reasoning).toBe(false);
expect(models[0]?.input).toEqual(["text"]);
expect(models[1]?.id).toBe("mistral.devstral-2-123b");
expect(models[1]?.reasoning).toBe(false);
expect(models[2]?.id).toBe("openai.gpt-oss-120b");
expect(models[2]?.reasoning).toBe(true); // GPT-OSS 120B supports reasoning
// Verify correct endpoint and auth header
expect(stringArgAt(mockFetch, 0, 0)).toBe("https://bedrock-mantle.us-east-1.api.aws/v1/models");
expect(recordField(objectArgAt(mockFetch, 0, 1).headers, "headers").Authorization).toBe(
"Bearer test-token",
);
});
it("infers reasoning support from model IDs", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
data: [
{ id: "moonshotai.kimi-k2-thinking", object: "model" },
{ id: "openai.gpt-oss-120b", object: "model" },
{ id: "openai.gpt-oss-safeguard-120b", object: "model" },
{ id: "deepseek.v3.2", object: "model" },
{ id: "mistral.mistral-large-3-675b-instruct", object: "model" },
],
}),
});
const models = await discoverMantleModels({
region: "us-east-1",
bearerToken: "test-token",
fetchFn: mockFetch as unknown as typeof fetch,
});
const byId = Object.fromEntries(models.map((m) => [m.id, m]));
expect(byId["moonshotai.kimi-k2-thinking"]?.reasoning).toBe(true);
expect(byId["openai.gpt-oss-120b"]?.reasoning).toBe(true);
expect(byId["openai.gpt-oss-safeguard-120b"]?.reasoning).toBe(true);
expect(byId["deepseek.v3.2"]?.reasoning).toBe(false);
expect(byId["mistral.mistral-large-3-675b-instruct"]?.reasoning).toBe(false);
});
it("returns empty array on permission error", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: false,
status: 403,
statusText: "Forbidden",
});
const models = await discoverMantleModels({
region: "us-east-1",
bearerToken: "test-token",
fetchFn: mockFetch as unknown as typeof fetch,
});
expect(models).toStrictEqual([]);
});
it("returns empty array on network error", async () => {
const mockFetch = vi.fn().mockRejectedValue(new Error("ECONNREFUSED"));
const models = await discoverMantleModels({
region: "us-east-1",
bearerToken: "test-token",
fetchFn: mockFetch as unknown as typeof fetch,
});
expect(models).toStrictEqual([]);
});
it("filters out models with empty IDs", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
data: [
{ id: "anthropic.claude-sonnet-4-6", object: "model" },
{ id: "", object: "model" },
{ id: " ", object: "model" },
],
}),
});
const models = await discoverMantleModels({
region: "us-east-1",
bearerToken: "test-token",
fetchFn: mockFetch as unknown as typeof fetch,
});
expect(models).toHaveLength(1);
expect(models[0]?.id).toBe("anthropic.claude-sonnet-4-6");
});
// ---------------------------------------------------------------------------
// Discovery caching
// ---------------------------------------------------------------------------
it("returns cached models on subsequent calls within refresh interval", async () => {
let now = 1000000;
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
data: [{ id: "anthropic.claude-sonnet-4-6", object: "model" }],
}),
});
// First call — hits the network
const first = await discoverMantleModels({
region: "us-east-1",
bearerToken: "test-token",
fetchFn: mockFetch as unknown as typeof fetch,
now: () => now,
});
expect(first).toHaveLength(1);
expect(mockFetch).toHaveBeenCalledTimes(1);
// Second call within refresh interval — uses cache
now += 60_000; // 1 minute later
const second = await discoverMantleModels({
region: "us-east-1",
bearerToken: "test-token",
fetchFn: mockFetch as unknown as typeof fetch,
now: () => now,
});
expect(second).toHaveLength(1);
expect(mockFetch).toHaveBeenCalledTimes(1); // No additional fetch
// Third call after refresh interval — re-fetches
now += 3600_000; // 1 hour later
const third = await discoverMantleModels({
region: "us-east-1",
bearerToken: "test-token",
fetchFn: mockFetch as unknown as typeof fetch,
now: () => now,
});
expect(third).toHaveLength(1);
expect(mockFetch).toHaveBeenCalledTimes(2); // Re-fetched
});
it("returns stale cache on fetch failure", async () => {
let now = 1000000;
const mockFetch = vi
.fn()
.mockResolvedValueOnce({
ok: true,
json: async () => ({
data: [{ id: "anthropic.claude-sonnet-4-6", object: "model" }],
}),
})
.mockRejectedValueOnce(new Error("ECONNREFUSED"));
// First call — succeeds
await discoverMantleModels({
region: "us-east-1",
bearerToken: "test-token",
fetchFn: mockFetch as unknown as typeof fetch,
now: () => now,
});
// Second call after expiry — fails but returns stale cache
now += 7200_000;
const stale = await discoverMantleModels({
region: "us-east-1",
bearerToken: "test-token",
fetchFn: mockFetch as unknown as typeof fetch,
now: () => now,
});
expect(stale).toHaveLength(1);
expect(stale[0]?.id).toBe("anthropic.claude-sonnet-4-6");
});
// ---------------------------------------------------------------------------
// Implicit provider resolution
// ---------------------------------------------------------------------------
it("resolves implicit provider when bearer token is set", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
data: [{ id: "anthropic.claude-sonnet-4-6", object: "model" }],
}),
});
const provider = await resolveImplicitMantleProvider({
env: {
AWS_BEARER_TOKEN_BEDROCK: "my-token", // pragma: allowlist secret
AWS_REGION: "us-east-1",
} as NodeJS.ProcessEnv,
fetchFn: mockFetch as unknown as typeof fetch,
});
expect(provider?.baseUrl).toBe("https://bedrock-mantle.us-east-1.api.aws/v1");
expect(provider?.api).toBe("openai-completions");
expect(provider?.auth).toBe("api-key");
expect(provider?.apiKey).toBe("env:AWS_BEARER_TOKEN_BEDROCK");
expect(provider?.models).toHaveLength(3);
const opus = provider?.models?.find((model) => model.id === "anthropic.claude-opus-4-7");
expect(opus?.api).toBe("anthropic-messages");
expect(opus?.reasoning).toBe(false);
expect(opus).not.toHaveProperty("baseUrl");
const mythos = provider?.models?.find(
(model) => model.id === "anthropic.claude-mythos-preview",
);
expect(mythos).toMatchObject({
api: "anthropic-messages",
reasoning: true,
params: { canonicalModelId: "claude-mythos-preview" },
contextWindow: 1_000_000,
maxTokens: 128_000,
});
});
it("returns null when no auth is available", async () => {
const tokenProviderFactory = vi.fn(() => {
throw new Error("no credentials");
});
const provider = await resolveImplicitMantleProvider({
env: {} as NodeJS.ProcessEnv,
tokenProviderFactory,
});
expect(provider).toBeNull();
});
it("uses a generated IAM token when no explicit token is set", async () => {
const tokenProvider = vi.fn(async () => "bedrock-api-key-iam"); // pragma: allowlist secret
const tokenProviderFactory = createTokenProviderFactory(tokenProvider);
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
data: [{ id: "openai.gpt-oss-120b", object: "model" }],
}),
});
const provider = await resolveImplicitMantleProvider({
env: {
AWS_PROFILE: "default",
AWS_REGION: "us-east-1",
} as NodeJS.ProcessEnv,
fetchFn: mockFetch as unknown as typeof fetch,
tokenProviderFactory,
});
expect(provider?.apiKey).toBe(MANTLE_IAM_TOKEN_MARKER);
expect(tokenProvider).toHaveBeenCalledTimes(1);
expect(stringArgAt(mockFetch, 0, 0)).toBe("https://bedrock-mantle.us-east-1.api.aws/v1/models");
expect(recordField(objectArgAt(mockFetch, 0, 1).headers, "headers").Authorization).toBe(
"Bearer bedrock-api-key-iam",
);
});
it("resolves Mantle runtime auth from the cached IAM token marker", async () => {
const tokenProvider = vi.fn(async () => "bedrock-api-key-runtime"); // pragma: allowlist secret
const tokenProviderFactory = createTokenProviderFactory(tokenProvider);
await generateBearerTokenFromIam({
region: "us-east-1",
now: () => 1000,
tokenProviderFactory,
});
const resolved = await resolveMantleRuntimeBearerToken({
apiKey: MANTLE_IAM_TOKEN_MARKER,
env: {
AWS_REGION: "us-east-1",
} as NodeJS.ProcessEnv,
now: () => 2000,
tokenProviderFactory,
});
expect(resolved?.apiKey).toBe("bedrock-api-key-runtime");
expect(resolved?.expiresAt).toBe(1000 + 7200_000);
expect(tokenProvider).toHaveBeenCalledTimes(1);
});
it("generates a fresh Mantle runtime IAM token when the cache is cold", async () => {
const tokenProvider = vi.fn(async () => "bedrock-api-key-fresh"); // pragma: allowlist secret
const tokenProviderFactory = createTokenProviderFactory(tokenProvider);
const resolved = await resolveMantleRuntimeBearerToken({
apiKey: MANTLE_IAM_TOKEN_MARKER,
env: {
AWS_REGION: "us-east-1",
} as NodeJS.ProcessEnv,
now: () => 5000,
tokenProviderFactory,
});
expect(resolved?.apiKey).toBe("bedrock-api-key-fresh");
expect(resolved?.expiresAt).toBe(5000 + 7200_000);
expect(tokenProvider).toHaveBeenCalledTimes(1);
});
it("omits Mantle runtime IAM token expiry when the process clock is invalid", async () => {
const tokenProvider = vi.fn(async () => "bedrock-api-key-invalid-clock"); // pragma: allowlist secret
const tokenProviderFactory = createTokenProviderFactory(tokenProvider);
const resolved = await resolveMantleRuntimeBearerToken({
apiKey: MANTLE_IAM_TOKEN_MARKER,
env: {
AWS_REGION: "us-east-1",
} as NodeJS.ProcessEnv,
now: () => Number.NaN,
tokenProviderFactory,
});
expect(resolved).toEqual({
apiKey: "bedrock-api-key-invalid-clock",
});
expect(tokenProvider).toHaveBeenCalledTimes(1);
});
it("returns null for unsupported regions", async () => {
const provider = await resolveImplicitMantleProvider({
env: {
AWS_BEARER_TOKEN_BEDROCK: "my-token", // pragma: allowlist secret
AWS_REGION: "af-south-1",
} as NodeJS.ProcessEnv,
});
expect(provider).toBeNull();
});
it("defaults to us-east-1 when no region is set", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ data: [{ id: "openai.gpt-oss-120b", object: "model" }] }),
});
const provider = await resolveImplicitMantleProvider({
env: {
AWS_BEARER_TOKEN_BEDROCK: "my-token", // pragma: allowlist secret
} as NodeJS.ProcessEnv,
fetchFn: mockFetch as unknown as typeof fetch,
});
expect(provider?.baseUrl).toBe("https://bedrock-mantle.us-east-1.api.aws/v1");
expect(stringArgAt(mockFetch, 0, 0)).toBe("https://bedrock-mantle.us-east-1.api.aws/v1/models");
objectArgAt(mockFetch, 0, 1);
});
// ---------------------------------------------------------------------------
// Provider merging
// ---------------------------------------------------------------------------
it("merges implicit models when existing provider has empty models", () => {
const result = mergeImplicitMantleProvider({
existing: {
baseUrl: "https://custom.example.com/v1",
models: [],
},
implicit: {
baseUrl: "https://bedrock-mantle.us-east-1.api.aws/v1",
api: "openai-completions",
auth: "api-key",
apiKey: "env:AWS_BEARER_TOKEN_BEDROCK",
models: [
{
id: "openai.gpt-oss-120b",
name: "GPT-OSS 120B",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 32000,
maxTokens: 4096,
},
],
},
});
expect(result.baseUrl).toBe("https://custom.example.com/v1");
expect(result.models?.map((m) => m.id)).toEqual(["openai.gpt-oss-120b"]);
});
it("preserves existing models over implicit ones", () => {
const result = mergeImplicitMantleProvider({
existing: {
baseUrl: "https://bedrock-mantle.us-east-1.api.aws/v1",
models: [
{
id: "custom-model",
name: "My Custom Model",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 64000,
maxTokens: 8192,
},
],
},
implicit: {
baseUrl: "https://bedrock-mantle.us-east-1.api.aws/v1",
api: "openai-completions",
auth: "api-key",
models: [
{
id: "openai.gpt-oss-120b",
name: "GPT-OSS 120B",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 32000,
maxTokens: 4096,
},
],
},
});
expect(result.models?.map((m) => m.id)).toEqual(["custom-model"]);
});
});

View File

@@ -0,0 +1,447 @@
/**
* Amazon Bedrock Mantle discovery and bearer-token handling. It resolves
* explicit tokens, IAM-generated tokens, model catalogs, and implicit provider config.
*/
import { createSubsystemLogger } from "openclaw/plugin-sdk/core";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import {
isFutureDateTimestampMs,
resolveExpiresAtMsFromDurationMs,
} from "openclaw/plugin-sdk/number-runtime";
import type {
ModelDefinitionConfig,
ModelProviderConfig,
} from "openclaw/plugin-sdk/provider-model-shared";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
const log = createSubsystemLogger("bedrock-mantle-discovery");
const DEFAULT_COST = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
};
const DEFAULT_CONTEXT_WINDOW = 32000;
const DEFAULT_MAX_TOKENS = 4096;
const DEFAULT_REFRESH_INTERVAL_SECONDS = 3600; // 1 hour
/** Config auth marker meaning Mantle should mint runtime bearer tokens from IAM. */
export const MANTLE_IAM_TOKEN_MARKER = "__amazon_bedrock_mantle_iam__";
// ---------------------------------------------------------------------------
// Mantle region & endpoint helpers
// ---------------------------------------------------------------------------
const MANTLE_SUPPORTED_REGIONS = [
"us-east-1",
"us-east-2",
"us-west-2",
"ap-northeast-1",
"ap-south-1",
"ap-southeast-3",
"eu-central-1",
"eu-west-1",
"eu-west-2",
"eu-south-1",
"eu-north-1",
"sa-east-1",
] as const;
function mantleEndpoint(region: string): string {
return `https://bedrock-mantle.${region}.api.aws`;
}
function isSupportedRegion(region: string): boolean {
return (MANTLE_SUPPORTED_REGIONS as readonly string[]).includes(region);
}
// ---------------------------------------------------------------------------
// Bearer token resolution
// ---------------------------------------------------------------------------
type MantleBearerTokenProvider = () => Promise<string>;
type MantleBearerTokenProviderFactory = (opts?: {
region?: string;
expiresInSeconds?: number;
}) => MantleBearerTokenProvider;
async function loadMantleBearerTokenProviderFactory(): Promise<MantleBearerTokenProviderFactory> {
const { getTokenProvider } = (await import("@aws/bedrock-token-generator")) as {
getTokenProvider: MantleBearerTokenProviderFactory;
};
return getTokenProvider;
}
/**
* Resolve a bearer token for Mantle authentication.
*
* Returns the value of AWS_BEARER_TOKEN_BEDROCK if set, undefined otherwise.
* When no explicit token is set, `resolveImplicitMantleProvider` will attempt
* to generate one from IAM credentials via `@aws/bedrock-token-generator`.
*/
export function resolveMantleBearerToken(env: NodeJS.ProcessEnv = process.env): string | undefined {
const explicitToken = env.AWS_BEARER_TOKEN_BEDROCK?.trim();
if (explicitToken) {
return explicitToken;
}
return undefined;
}
/** Token cache for IAM-derived bearer tokens, keyed by region. */
const iamTokenCache = new Map<string, { token: string; expiresAt: number }>();
const IAM_TOKEN_TTL_MS = 7200_000; // Matches the 2h token lifetime we request below.
function resolveMantleRegion(env: NodeJS.ProcessEnv): string {
return env.AWS_REGION ?? env.AWS_DEFAULT_REGION ?? "us-east-1";
}
function getCachedIamTokenEntry(
region: string,
now: number = Date.now(),
): { token: string; expiresAt: number } | undefined {
const cached = iamTokenCache.get(region);
if (cached && isFutureDateTimestampMs(cached.expiresAt, { nowMs: now })) {
return cached;
}
iamTokenCache.delete(region);
return undefined;
}
/**
* Generate a bearer token from IAM credentials using `@aws/bedrock-token-generator`.
*
* Uses the AWS default credential chain (instance roles, SSO, access keys, EKS IRSA).
* Returns undefined if the package is not installed or credentials are unavailable.
*/
export async function generateBearerTokenFromIam(params: {
region: string;
now?: () => number;
tokenProviderFactory?: MantleBearerTokenProviderFactory;
}): Promise<string | undefined> {
const now = params.now?.() ?? Date.now();
const cached = getCachedIamTokenEntry(params.region, now);
if (cached) {
return cached.token;
}
try {
const getTokenProvider =
params.tokenProviderFactory ?? (await loadMantleBearerTokenProviderFactory());
const token = await getTokenProvider({
region: params.region,
expiresInSeconds: 7200, // 2 hours
})();
const expiresAt = resolveExpiresAtMsFromDurationMs(IAM_TOKEN_TTL_MS, { nowMs: now });
if (expiresAt !== undefined) {
iamTokenCache.set(params.region, { token, expiresAt });
}
return token;
} catch (error) {
log.debug?.("Mantle IAM token generation unavailable", {
region: params.region,
error: formatErrorMessage(error),
});
return undefined;
}
}
/**
* Read a cached IAM bearer token for the given region (sync, no generation).
*
* Returns the token if it exists and has not expired, undefined otherwise.
* Used by Mantle runtime auth and tests to inspect the current cache.
*/
export function getCachedIamToken(region: string): string | undefined {
return getCachedIamTokenEntry(region)?.token;
}
/** Resolve the actual runtime bearer token for Mantle, generating IAM tokens when needed. */
export async function resolveMantleRuntimeBearerToken(params: {
apiKey: string;
env?: NodeJS.ProcessEnv;
now?: () => number;
tokenProviderFactory?: MantleBearerTokenProviderFactory;
}): Promise<{ apiKey: string; expiresAt?: number } | undefined> {
if (params.apiKey !== MANTLE_IAM_TOKEN_MARKER) {
return { apiKey: params.apiKey };
}
const now = params.now?.() ?? Date.now();
const region = resolveMantleRegion(params.env ?? process.env);
const cached = getCachedIamTokenEntry(region, now);
if (cached) {
return {
apiKey: cached.token,
expiresAt: cached.expiresAt,
};
}
const token = await generateBearerTokenFromIam({
region,
now: params.now,
tokenProviderFactory: params.tokenProviderFactory,
});
if (!token) {
return undefined;
}
const refreshed = getCachedIamTokenEntry(region, now);
const expiresAt =
refreshed?.expiresAt ?? resolveExpiresAtMsFromDurationMs(IAM_TOKEN_TTL_MS, { nowMs: now });
return {
apiKey: refreshed?.token ?? token,
...(expiresAt === undefined ? {} : { expiresAt }),
};
}
/** Clear the IAM token cache for tests. */
export function resetIamTokenCacheForTest(): void {
iamTokenCache.clear();
}
// ---------------------------------------------------------------------------
// OpenAI-format model list response
// ---------------------------------------------------------------------------
interface OpenAIModelEntry {
id: string;
object?: string;
owned_by?: string;
created?: number;
}
interface OpenAIModelsResponse {
data?: OpenAIModelEntry[];
object?: string;
}
// ---------------------------------------------------------------------------
// Reasoning heuristic
// ---------------------------------------------------------------------------
/** Model ID substrings that indicate reasoning/thinking support. */
const REASONING_PATTERNS = [
"thinking",
"reasoner",
"reasoning",
"deepseek.r",
"gpt-oss-120b", // GPT-OSS 120B supports reasoning
"gpt-oss-safeguard-120b",
];
function inferReasoningSupport(modelId: string): boolean {
const lower = normalizeLowercaseStringOrEmpty(modelId);
return REASONING_PATTERNS.some((p) => lower.includes(p));
}
// ---------------------------------------------------------------------------
// Discovery cache
// ---------------------------------------------------------------------------
interface MantleCacheEntry {
models: ModelDefinitionConfig[];
fetchedAt: number;
}
type MantleDiscoveryConfig = {
enabled?: boolean;
};
const discoveryCache = new Map<string, MantleCacheEntry>();
/** Clear the Mantle discovery cache for tests. */
export function resetMantleDiscoveryCacheForTest(): void {
discoveryCache.clear();
}
// ---------------------------------------------------------------------------
// Model discovery
// ---------------------------------------------------------------------------
/**
* Discover available models from the Mantle `/v1/models` endpoint.
*
* The response is in standard OpenAI format:
* ```json
* { "data": [{ "id": "anthropic.claude-sonnet-4-6", "object": "model", "owned_by": "anthropic" }] }
* ```
*
* Results are cached per region for `DEFAULT_REFRESH_INTERVAL_SECONDS`.
* Returns an empty array if the request fails (no permission, network error, etc.).
*/
/** Discover Mantle models for one region/config. */
export async function discoverMantleModels(params: {
region: string;
bearerToken: string;
fetchFn?: typeof fetch;
now?: () => number;
}): Promise<ModelDefinitionConfig[]> {
const { region, bearerToken, fetchFn = fetch, now = Date.now } = params;
// Check cache
const cacheKey = region;
const cached = discoveryCache.get(cacheKey);
if (cached && now() - cached.fetchedAt < DEFAULT_REFRESH_INTERVAL_SECONDS * 1000) {
return cached.models;
}
const endpoint = `${mantleEndpoint(region)}/v1/models`;
try {
const response = await fetchFn(endpoint, {
method: "GET",
headers: {
Authorization: `Bearer ${bearerToken}`,
Accept: "application/json",
},
});
if (!response.ok) {
log.debug?.("Mantle model discovery failed", {
status: response.status,
statusText: response.statusText,
});
return cached?.models ?? [];
}
const body = (await response.json()) as OpenAIModelsResponse;
const rawModels = body.data ?? [];
const models = rawModels
.filter((m) => m.id?.trim())
.map((m) => ({
id: m.id,
name: m.id, // Mantle doesn't return display names
reasoning: inferReasoningSupport(m.id),
input: ["text" as const],
cost: DEFAULT_COST,
contextWindow: DEFAULT_CONTEXT_WINDOW,
maxTokens: DEFAULT_MAX_TOKENS,
}))
.toSorted((a, b) => a.id.localeCompare(b.id));
discoveryCache.set(cacheKey, { models, fetchedAt: now() });
return models;
} catch (error) {
log.debug?.("Mantle model discovery error", {
error: formatErrorMessage(error),
});
return cached?.models ?? [];
}
}
// ---------------------------------------------------------------------------
// Implicit provider resolution
// ---------------------------------------------------------------------------
/**
* Resolve an implicit Bedrock Mantle provider if authentication is available.
*
* Detection priority:
* 1. AWS_BEARER_TOKEN_BEDROCK env var → use directly
* 2. IAM credentials → generate bearer token via `@aws/bedrock-token-generator`
* - Region from AWS_REGION / AWS_DEFAULT_REGION / default us-east-1
* - Models discovered from `/v1/models`
*/
/** Resolve implicit Mantle provider config from env, IAM token support, and discovery. */
export async function resolveImplicitMantleProvider(params: {
env?: NodeJS.ProcessEnv;
pluginConfig?: { discovery?: MantleDiscoveryConfig };
fetchFn?: typeof fetch;
tokenProviderFactory?: MantleBearerTokenProviderFactory;
}): Promise<ModelProviderConfig | null> {
const env = params.env ?? process.env;
if (params.pluginConfig?.discovery?.enabled === false) {
return null;
}
const region = resolveMantleRegion(env);
const explicitBearerToken = resolveMantleBearerToken(env);
if (!isSupportedRegion(region)) {
log.debug?.("Mantle not available in region", { region });
return null;
}
// Try explicit token first, then generate from IAM credentials
const bearerToken =
explicitBearerToken ??
(await generateBearerTokenFromIam({
region,
tokenProviderFactory: params.tokenProviderFactory,
}));
if (!bearerToken) {
return null;
}
const models = await discoverMantleModels({
region,
bearerToken,
fetchFn: params.fetchFn,
});
if (models.length === 0) {
return null;
}
log.debug?.("Mantle provider resolved", { region, modelCount: models.length });
// Append Claude models available on Mantle's Anthropic Messages endpoint.
// Opus 4.7 currently needs the provider-owned bearer-auth path here, but we
// keep reasoning off until the underlying Anthropic transport learns Opus 4.7
// adaptive thinking semantics.
const claudeModels: ModelDefinitionConfig[] = [
{
id: "anthropic.claude-opus-4-7",
name: "Claude Opus 4.7",
api: "anthropic-messages" as const,
reasoning: false,
input: ["text", "image"],
cost: {
input: 5,
output: 25,
cacheRead: 0.5,
cacheWrite: 6.25,
},
contextWindow: 1_000_000,
maxTokens: 128_000,
},
{
id: "anthropic.claude-mythos-preview",
name: "Claude Mythos Preview",
api: "anthropic-messages" as const,
reasoning: true,
params: { canonicalModelId: "claude-mythos-preview" },
input: ["text", "image"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 1_000_000,
maxTokens: 128_000,
},
];
const allModels = [...models, ...claudeModels];
return {
baseUrl: `${mantleEndpoint(region)}/v1`,
api: "openai-completions",
auth: "api-key",
apiKey: explicitBearerToken ? "env:AWS_BEARER_TOKEN_BEDROCK" : MANTLE_IAM_TOKEN_MARKER,
models: allModels,
};
}
/** Merge an implicit Mantle provider catalog with explicit user config. */
export function mergeImplicitMantleProvider(params: {
existing: ModelProviderConfig | undefined;
implicit: ModelProviderConfig;
}): ModelProviderConfig {
const { existing, implicit } = params;
if (!existing) {
return implicit;
}
return {
...implicit,
...existing,
models:
Array.isArray(existing.models) && existing.models.length > 0
? existing.models
: implicit.models,
};
}

View File

@@ -0,0 +1,88 @@
// Amazon Bedrock Mantle tests cover index plugin behavior.
import { registerSingleProviderPlugin } from "openclaw/plugin-sdk/plugin-test-runtime";
import { beforeEach, describe, expect, it, vi } from "vitest";
import bedrockMantlePlugin from "./index.js";
describe("amazon-bedrock-mantle provider plugin", () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it("uses live plugin config to disable catalog discovery", async () => {
const fetchMock = vi
.spyOn(globalThis, "fetch")
.mockRejectedValue(new Error("unexpected fetch"));
const provider = await registerSingleProviderPlugin(bedrockMantlePlugin);
const catalog = provider.catalog;
if (!catalog) {
throw new Error("catalog registration missing");
}
const result = await catalog.run({
config: {
plugins: {
entries: {
"amazon-bedrock-mantle": {
config: {
discovery: { enabled: false },
},
},
},
},
},
env: {
AWS_BEARER_TOKEN_BEDROCK: "test-token",
AWS_REGION: "us-east-1",
},
} as never);
expect(result).toBeNull();
expect(fetchMock).not.toHaveBeenCalled();
});
it("registers with correct provider ID and label", async () => {
const provider = await registerSingleProviderPlugin(bedrockMantlePlugin);
expect(provider.id).toBe("amazon-bedrock-mantle");
expect(provider.label).toBe("Amazon Bedrock Mantle (OpenAI-compatible)");
});
it("classifies rate limit errors for failover", async () => {
const provider = await registerSingleProviderPlugin(bedrockMantlePlugin);
expect(
provider.classifyFailoverReason?.({ errorMessage: "rate_limit exceeded" } as never),
).toBe("rate_limit");
expect(
provider.classifyFailoverReason?.({ errorMessage: "429 Too Many Requests" } as never),
).toBe("rate_limit");
expect(
provider.classifyFailoverReason?.({ errorMessage: "some other error" } as never),
).toBeUndefined();
expect(provider.classifyFailoverReason?.({ errorMessage: "overloaded_error" } as never)).toBe(
"overloaded",
);
});
it("provides a custom stream only for Mantle Anthropic models", async () => {
const provider = await registerSingleProviderPlugin(bedrockMantlePlugin);
expect(
typeof provider.createStreamFn?.({
provider: "amazon-bedrock-mantle",
modelId: "anthropic.claude-opus-4-7",
model: {
api: "anthropic-messages",
},
} as never),
).toBe("function");
expect(
provider.createStreamFn?.({
provider: "amazon-bedrock-mantle",
modelId: "openai.gpt-oss-120b",
model: {
api: "openai-completions",
},
} as never),
).toBeUndefined();
});
});

View File

@@ -0,0 +1,15 @@
/**
* Amazon Bedrock Mantle plugin entry. Registers the OpenAI-compatible Mantle
* provider plus Anthropic stream compatibility hooks.
*/
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import { registerBedrockMantlePlugin } from "./register.sync.runtime.js";
export default definePluginEntry({
id: "amazon-bedrock-mantle",
name: "Amazon Bedrock Mantle Provider",
description: "Bundled Amazon Bedrock Mantle (OpenAI-compatible) provider plugin",
register(api) {
registerBedrockMantlePlugin(api);
},
});

View File

@@ -0,0 +1,187 @@
// Amazon Bedrock Mantle tests cover mantle anthropic plugin behavior.
import type { Model } from "openclaw/plugin-sdk/llm";
import { describe, expect, it, vi } from "vitest";
import {
createMantleAnthropicStreamFn,
resolveMantleAnthropicBaseUrl,
} from "./mantle-anthropic.runtime.js";
function createTestModel(overrides: Partial<Model> = {}): Model {
return {
id: "anthropic.claude-opus-4-7",
name: "Claude Opus 4.7",
provider: "amazon-bedrock-mantle",
api: "anthropic-messages",
baseUrl: "https://bedrock-mantle.us-east-1.api.aws/v1",
headers: {
"X-Test": "model-header",
},
reasoning: false,
input: ["text", "image"],
cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
contextWindow: 1_000_000,
maxTokens: 128_000,
...overrides,
} as Model;
}
function createTestDeps() {
return {
createClient: vi.fn((options: unknown) => ({ options }) as never),
stream: vi.fn(),
};
}
function requireRecord(value: unknown, label: string): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error(`Expected ${label} to be an object`);
}
return value as Record<string, unknown>;
}
function mockCallArg(mock: { mock: { calls: unknown[][] } }, index = 0, argIndex = 0): unknown {
const call = mock.mock.calls[index];
if (!call) {
throw new Error(`expected mock call ${index}`);
}
return call[argIndex];
}
function expectFirstStreamCall(
deps: ReturnType<typeof createTestDeps>,
model: Model,
context: unknown,
) {
expect(mockCallArg(deps.stream, 0, 0)).toBe(model);
expect(mockCallArg(deps.stream, 0, 1)).toBe(context);
}
function firstStreamOptions(deps: ReturnType<typeof createTestDeps>): Record<string, unknown> {
return requireRecord(mockCallArg(deps.stream, 0, 2), "stream options");
}
describe("createMantleAnthropicStreamFn", () => {
it("uses authToken bearer auth for Mantle Anthropic requests", () => {
const stream = { kind: "anthropic-stream" };
const model = createTestModel();
const context = { messages: [] };
const deps = createTestDeps();
deps.stream.mockReturnValue(stream as never);
const result = createMantleAnthropicStreamFn(deps)(model, context, {
apiKey: "bedrock-bearer-token",
headers: {
"X-Caller": "caller-header",
},
});
expect(result).toBe(stream);
const clientOptions = requireRecord(mockCallArg(deps.createClient), "client options");
expect(clientOptions.apiKey).toBeNull();
expect(clientOptions.authToken).toBe("bedrock-bearer-token");
expect(clientOptions.baseURL).toBe("https://bedrock-mantle.us-east-1.api.aws/anthropic");
const defaultHeaders = requireRecord(clientOptions.defaultHeaders, "default headers");
expect(defaultHeaders.accept).toBe("application/json");
expect(defaultHeaders["anthropic-beta"]).toBe("fine-grained-tool-streaming-2025-05-14");
expect(defaultHeaders["X-Test"]).toBe("model-header");
expect(defaultHeaders["X-Caller"]).toBe("caller-header");
expectFirstStreamCall(deps, model, context);
const streamOptions = firstStreamOptions(deps);
const client = requireRecord(streamOptions.client, "stream client");
expect(requireRecord(client.options, "stream client options").authToken).toBe(
"bedrock-bearer-token",
);
expect(streamOptions.thinkingEnabled).toBe(false);
});
it("omits unsupported Opus 4.7 sampling and reasoning overrides", () => {
const model = createTestModel();
const context = { messages: [] };
const deps = createTestDeps();
deps.stream.mockReturnValue({ kind: "anthropic-stream" } as never);
void createMantleAnthropicStreamFn(deps)(model, context, {
apiKey: "bedrock-bearer-token",
temperature: 0.2,
reasoning: "high",
});
expectFirstStreamCall(deps, model, context);
const streamOptions = firstStreamOptions(deps);
expect(streamOptions.temperature).toBeUndefined();
expect(streamOptions.thinkingEnabled).toBe(false);
});
it("defaults Mythos Preview to adaptive high effort", () => {
const model = createTestModel({
id: "anthropic.claude-mythos-preview",
name: "Claude Mythos Preview",
reasoning: true,
params: { canonicalModelId: "claude-mythos-preview" },
});
const context = { messages: [] };
const deps = createTestDeps();
deps.stream.mockReturnValue({ kind: "anthropic-stream" } as never);
void createMantleAnthropicStreamFn(deps)(model, context, {
apiKey: "bedrock-bearer-token",
});
expectFirstStreamCall(deps, model, context);
const streamOptions = firstStreamOptions(deps);
expect(streamOptions.thinkingEnabled).toBe(true);
expect(streamOptions.effort).toBe("high");
});
it("clamps unsupported Mythos Preview max effort to high", () => {
const model = createTestModel({
id: "anthropic.claude-mythos-preview",
name: "Claude Mythos Preview",
reasoning: true,
params: { canonicalModelId: "claude-mythos-preview" },
});
const context = { messages: [] };
const deps = createTestDeps();
deps.stream.mockReturnValue({ kind: "anthropic-stream" } as never);
void createMantleAnthropicStreamFn(deps)(model, context, {
apiKey: "bedrock-bearer-token",
reasoning: "max",
});
expectFirstStreamCall(deps, model, context);
const streamOptions = firstStreamOptions(deps);
expect(streamOptions.thinkingEnabled).toBe(true);
expect(streamOptions.effort).toBe("high");
});
it("maps Mythos Preview minimal reasoning to low effort", () => {
const model = createTestModel({
id: "anthropic.claude-mythos-preview",
name: "Claude Mythos Preview",
reasoning: true,
params: { canonicalModelId: "claude-mythos-preview" },
});
const deps = createTestDeps();
deps.stream.mockReturnValue({ kind: "anthropic-stream" } as never);
void createMantleAnthropicStreamFn(deps)(model, { messages: [] }, {
apiKey: "bedrock-bearer-token",
reasoning: "minimal",
});
const streamOptions = firstStreamOptions(deps);
expect(streamOptions.thinkingEnabled).toBe(true);
expect(streamOptions.effort).toBe("low");
});
it("normalizes Mantle provider URLs to the Anthropic endpoint", () => {
expect(resolveMantleAnthropicBaseUrl("https://bedrock-mantle.us-east-1.api.aws/v1")).toBe(
"https://bedrock-mantle.us-east-1.api.aws/anthropic",
);
expect(
resolveMantleAnthropicBaseUrl("https://bedrock-mantle.us-east-1.api.aws/anthropic/"),
).toBe("https://bedrock-mantle.us-east-1.api.aws/anthropic");
});
});

View File

@@ -0,0 +1,165 @@
/**
* Anthropic Messages stream adapter for Bedrock Mantle. It rewrites Mantle
* endpoints to Anthropic-compatible URLs and adjusts thinking-token budgets.
*/
import Anthropic from "@anthropic-ai/sdk";
import type { StreamFn } from "openclaw/plugin-sdk/agent-core";
import { stream, type Model, type SimpleStreamOptions } from "openclaw/plugin-sdk/llm";
const MANTLE_ANTHROPIC_BETA = "fine-grained-tool-streaming-2025-05-14";
type AnthropicOptions = ConstructorParameters<typeof Anthropic>[0];
type MantleAnthropicStream = typeof stream;
/** Resolve the Anthropic-compatible Mantle base URL from a provider base URL. */
export function resolveMantleAnthropicBaseUrl(baseUrl: string): string {
const trimmed = baseUrl.replace(/\/+$/, "");
if (trimmed.endsWith("/anthropic")) {
return trimmed;
}
if (trimmed.endsWith("/v1")) {
return `${trimmed.slice(0, -"/v1".length)}/anthropic`;
}
return `${trimmed}/anthropic`;
}
function requiresDefaultSampling(modelId: string): boolean {
return modelId.includes("claude-opus-4-7");
}
function isClaudeMythosPreviewModel(model: Model): boolean {
return [model.id, model.name, model.params?.canonicalModelId]
.filter((value): value is string => typeof value === "string")
.some((value) =>
/(?:^|-)claude-mythos-preview(?=$|[^a-z0-9])/.test(
value
.trim()
.toLowerCase()
.replace(/[\s_.:]+/g, "-"),
),
);
}
function resolveMantleReasoning(
model: Model,
options: SimpleStreamOptions | undefined,
): NonNullable<SimpleStreamOptions["reasoning"]> | undefined {
if (requiresDefaultSampling(model.id)) {
return undefined;
}
const reasoning = options?.reasoning ?? (isClaudeMythosPreviewModel(model) ? "high" : undefined);
if (!isClaudeMythosPreviewModel(model)) {
return reasoning;
}
if (reasoning === "minimal") {
return "low";
}
return reasoning === "xhigh" || reasoning === "max" ? "high" : reasoning;
}
function mergeHeaders(
...headerSources: Array<Record<string, string> | undefined>
): Record<string, string> {
const merged: Record<string, string> = {};
for (const headers of headerSources) {
if (headers) {
Object.assign(merged, headers);
}
}
return merged;
}
function buildMantleAnthropicBaseOptions(
model: Model,
options: SimpleStreamOptions | undefined,
apiKey: string,
) {
return {
temperature: requiresDefaultSampling(model.id) ? undefined : options?.temperature,
maxTokens: options?.maxTokens || Math.min(model.maxTokens, 32_000),
signal: options?.signal,
apiKey,
cacheRetention: options?.cacheRetention,
sessionId: options?.sessionId,
onPayload: options?.onPayload,
maxRetryDelayMs: options?.maxRetryDelayMs,
metadata: options?.metadata,
};
}
function adjustMaxTokensForThinking(
baseMaxTokens: number,
modelMaxTokens: number,
reasoningLevel: NonNullable<SimpleStreamOptions["reasoning"]>,
customBudgets?: SimpleStreamOptions["thinkingBudgets"],
): { maxTokens: number; thinkingBudget: number } {
const defaultBudgets = {
minimal: 1024,
low: 2048,
medium: 8192,
high: 16384,
xhigh: 16384,
max: 16384,
} as const;
const budgets = { ...defaultBudgets, ...customBudgets };
const minOutputTokens = 1024;
let thinkingBudget = budgets[reasoningLevel];
const maxTokens = Math.min(baseMaxTokens + thinkingBudget, modelMaxTokens);
if (maxTokens <= thinkingBudget) {
thinkingBudget = Math.max(0, maxTokens - minOutputTokens);
}
return { maxTokens, thinkingBudget };
}
/** Create the Mantle Anthropic Messages stream function. */
export function createMantleAnthropicStreamFn(deps?: {
createClient?: (options: AnthropicOptions) => Anthropic;
stream?: MantleAnthropicStream;
}): StreamFn {
return (model, context, options) => {
const apiKey = options?.apiKey ?? "";
const createClient = deps?.createClient ?? ((clientOptions) => new Anthropic(clientOptions));
const streamFn = deps?.stream ?? stream;
const client = createClient({
apiKey: null,
authToken: apiKey,
baseURL: resolveMantleAnthropicBaseUrl(model.baseUrl),
dangerouslyAllowBrowser: true,
defaultHeaders: mergeHeaders(
{
accept: "application/json",
"anthropic-dangerous-direct-browser-access": "true",
"anthropic-beta": MANTLE_ANTHROPIC_BETA,
},
model.headers,
options?.headers,
),
});
const base = buildMantleAnthropicBaseOptions(model, options, apiKey);
// Plugin package deps can give this plugin a distinct physical SDK copy.
// The client API is the same, but the SDK class private field makes types nominal.
const streamClient = client as unknown as Anthropic;
const reasoning = resolveMantleReasoning(model, options);
if (!reasoning) {
return streamFn(model as Model<"anthropic-messages">, context, {
...base,
client: streamClient,
thinkingEnabled: false,
});
}
const adjusted = adjustMaxTokensForThinking(
base.maxTokens || 0,
model.maxTokens,
reasoning,
options?.thinkingBudgets,
);
return streamFn(model as Model<"anthropic-messages">, context, {
...base,
client: streamClient,
maxTokens: adjusted.maxTokens,
thinkingEnabled: true,
...(isClaudeMythosPreviewModel(model) ? { effort: reasoning } : {}),
thinkingBudgetTokens: adjusted.thinkingBudget,
});
};
}

View File

@@ -0,0 +1,594 @@
{
"name": "@openclaw/amazon-bedrock-mantle-provider",
"version": "2026.6.11",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@openclaw/amazon-bedrock-mantle-provider",
"version": "2026.6.11",
"dependencies": {
"@anthropic-ai/sdk": "0.109.1",
"@aws/bedrock-token-generator": "1.1.0"
}
},
"node_modules/@anthropic-ai/sdk": {
"version": "0.109.1",
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.109.1.tgz",
"integrity": "sha512-q9OnEKLr5H9nxSuXdgDgJhxfYMiE+AaUEBze2Gk91UcaaLnsN+Lx5fbCYywiqurU/APLdwv23x03Wm6WN3EBsg==",
"license": "MIT",
"dependencies": {
"json-schema-to-ts": "^3.1.1",
"standardwebhooks": "^1.0.0"
},
"bin": {
"anthropic-ai-sdk": "bin/cli"
},
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"
},
"peerDependenciesMeta": {
"zod": {
"optional": true
}
}
},
"node_modules/@aws-sdk/client-cognito-identity": {
"version": "3.1078.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/client-cognito-identity/-/client-cognito-identity-3.1078.0.tgz",
"integrity": "sha512-BYy0X/+GMXlitKShxkdTsCexWwDrn8usY2Y2Z06M5MSi4aRT3Ce5ilyA6OubQUqOWfsmDMYrm8oBNaTIcQFyrg==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.974.26",
"@aws-sdk/credential-provider-node": "^3.972.61",
"@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-cognito-identity": {
"version": "3.972.51",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-cognito-identity/-/credential-provider-cognito-identity-3.972.51.tgz",
"integrity": "sha512-nXzAwRz0NOiHlG/HHea7oJ2ew2m21XZUU6h2cZMCrlNQqcWjMHCkun4D6E7CWqOxiFG0MeN8Gg5Iakrjv/UXrQ==",
"license": "Apache-2.0",
"dependencies": {
"@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-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/credential-providers": {
"version": "3.1078.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-providers/-/credential-providers-3.1078.0.tgz",
"integrity": "sha512-V9Tr3MrNWUfTGgTMIr+WJaMC/VDbXY57BzrGDuyDZn7+vgZjAEG6nI5nMdfnTGdvV9wq1n0zZPYW2RDfcsWNCw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/client-cognito-identity": "3.1078.0",
"@aws-sdk/core": "^3.974.26",
"@aws-sdk/credential-provider-cognito-identity": "^3.972.51",
"@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-login": "^3.972.58",
"@aws-sdk/credential-provider-node": "^3.972.61",
"@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/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/util-format-url": {
"version": "3.972.28",
"resolved": "https://registry.npmjs.org/@aws-sdk/util-format-url/-/util-format-url-3.972.28.tgz",
"integrity": "sha512-nPBeLFpFaLepgKP8e87fVrDrEKV6AgYEcjRwsQyxcfw2RguMyZSeR31kM7AcCvQpJO/iK+JFzNAmN50vmcUeWg==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.974.26",
"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/bedrock-token-generator": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@aws/bedrock-token-generator/-/bedrock-token-generator-1.1.0.tgz",
"integrity": "sha512-i+DkWnfdA4j4sffy9dI4k3OGoOWqN8CTGdtO4IZ3c0kpKYFr6KyqzqLQmoRNrF3ACFcWj6u+J6cbBQ97j9wx5w==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/credential-providers": "^3.525.0",
"@aws-sdk/util-format-url": ">=3.525.0",
"@smithy/config-resolver": "^4.1.4",
"@smithy/hash-node": ">=2.1.3",
"@smithy/invalid-dependency": "^4.0.4",
"@smithy/node-config-provider": "^4.1.3",
"@smithy/protocol-http": ">=3.2.1",
"@smithy/signature-v4": ">=2.1.3",
"@smithy/types": ">=2.11.0"
},
"engines": {
"node": ">=16.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/@babel/runtime": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@smithy/config-resolver": {
"version": "4.6.5",
"resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.6.5.tgz",
"integrity": "sha512-EWaWeWXmEa2BMk6x0I++Nec4lMmTgzBI48ri7Ct6vv3YdcTzM3JrHMC3R3JkUVohMxA5D81WDhcCP+fs6kv4AQ==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/core": "^3.29.0",
"tslib": "^2.6.2"
},
"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/hash-node": {
"version": "4.4.5",
"resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.4.5.tgz",
"integrity": "sha512-2pyNsNH0rQJLTdIYDpona33axUSwhTPjJ5wl4twukJpEwgvltNL/mqZ7Tcgohz4DB8rhFwRPDd/3BlYuIvMYvw==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/core": "^3.29.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/invalid-dependency": {
"version": "4.4.5",
"resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.4.5.tgz",
"integrity": "sha512-CzXd06th+MmAoq7dfmP2Olg4ZIpnkcJnnx20Kgusc6dBC0we6+nyaJEdY78aGiAX95Oss4x4FRS6GlXttVzN6w==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/core": "^3.29.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/node-config-provider": {
"version": "4.5.5",
"resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.5.5.tgz",
"integrity": "sha512-oYaPOb+00xXWDUb5t05b0trcnZ4XSr/cDKioiZSwSKk8crvnu8AlElTddpq6s9HT4lELJuuduCAsbco8AjZUOQ==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/core": "^3.29.0",
"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/protocol-http": {
"version": "5.5.5",
"resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.5.5.tgz",
"integrity": "sha512-3qVnJJQN0P5tHAui6Pusz958lyXLgUezbh3wiDL6xqMY90TvSB7knLIDNr2xPCF2E5TsUfYt5aRUS3466RFkuQ==",
"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/@stablelib/base64": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
"integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
"license": "MIT"
},
"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/fast-sha256": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
"integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
"license": "Unlicense"
},
"node_modules/json-schema-to-ts": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz",
"integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.18.3",
"ts-algebra": "^2.0.0"
},
"engines": {
"node": ">=16"
}
},
"node_modules/standardwebhooks": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz",
"integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==",
"license": "MIT",
"dependencies": {
"@stablelib/base64": "^1.0.0",
"fast-sha256": "^1.3.0"
}
},
"node_modules/ts-algebra": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz",
"integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==",
"license": "MIT"
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
}
}
}

View File

@@ -0,0 +1,36 @@
{
"id": "amazon-bedrock-mantle",
"name": "Amazon Bedrock Mantle",
"description": "OpenClaw Amazon Bedrock Mantle provider plugin for OpenAI-compatible model routing.",
"activation": {
"onStartup": false
},
"enabledByDefault": true,
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"discovery": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean",
"description": "When false, skip implicit Mantle model discovery."
}
}
}
}
},
"uiHints": {
"discovery": {
"label": "Model Discovery",
"help": "Plugin-owned controls for Amazon Bedrock Mantle model auto-discovery."
},
"discovery.enabled": {
"label": "Enable Discovery",
"help": "When false, OpenClaw keeps the Amazon Bedrock Mantle plugin available but skips implicit startup discovery. Leave unset for default auto-detect behavior."
}
},
"providers": ["amazon-bedrock-mantle"]
}

View File

@@ -0,0 +1,38 @@
{
"name": "@openclaw/amazon-bedrock-mantle-provider",
"version": "2026.6.11",
"description": "OpenClaw Amazon Bedrock Mantle provider plugin for OpenAI-compatible model routing.",
"repository": {
"type": "git",
"url": "https://github.com/openclaw/openclaw"
},
"type": "module",
"dependencies": {
"@anthropic-ai/sdk": "0.109.1",
"@aws/bedrock-token-generator": "1.1.0"
},
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*"
},
"openclaw": {
"extensions": [
"./index.ts"
],
"install": {
"npmSpec": "@openclaw/amazon-bedrock-mantle-provider",
"defaultChoice": "npm",
"minHostVersion": ">=2026.5.12-beta.1"
},
"compat": {
"pluginApi": ">=2026.6.11"
},
"build": {
"openclawVersion": "2026.6.11",
"bundledDist": false
},
"release": {
"publishToClawHub": true,
"publishToNpm": true
}
}
}

View File

@@ -0,0 +1,82 @@
/**
* Synchronous Amazon Bedrock Mantle provider registration. It wires discovery,
* runtime bearer-token preparation, stream wrappers, and failover classifiers.
*/
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { resolvePluginConfigObject } from "openclaw/plugin-sdk/plugin-config-runtime";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
import {
mergeImplicitMantleProvider,
resolveImplicitMantleProvider,
resolveMantleBearerToken,
resolveMantleRuntimeBearerToken,
} from "./discovery.js";
import { createMantleAnthropicStreamFn } from "./mantle-anthropic.runtime.js";
type BedrockMantlePluginConfig = {
discovery?: {
enabled?: boolean;
};
};
/** Register the Amazon Bedrock Mantle provider with OpenClaw. */
export function registerBedrockMantlePlugin(api: OpenClawPluginApi): void {
const providerId = "amazon-bedrock-mantle";
const startupPluginConfig = (api.pluginConfig ?? {}) as BedrockMantlePluginConfig;
function resolveCurrentPluginConfig(
config: OpenClawConfig | undefined,
): BedrockMantlePluginConfig | undefined {
const runtimePluginConfig = resolvePluginConfigObject(config, providerId);
return (
(runtimePluginConfig as BedrockMantlePluginConfig | undefined) ??
(config ? undefined : startupPluginConfig)
);
}
api.registerProvider({
id: providerId,
label: "Amazon Bedrock Mantle (OpenAI-compatible)",
docsPath: "/providers/bedrock-mantle",
auth: [],
catalog: {
order: "simple",
run: async (ctx) => {
const currentPluginConfig = resolveCurrentPluginConfig(ctx.config);
const implicit = await resolveImplicitMantleProvider({
env: ctx.env,
pluginConfig: currentPluginConfig,
});
if (!implicit) {
return null;
}
return {
provider: mergeImplicitMantleProvider({
existing: ctx.config.models?.providers?.[providerId],
implicit,
}),
};
},
},
resolveConfigApiKey: ({ env }) =>
resolveMantleBearerToken(env) ? "env:AWS_BEARER_TOKEN_BEDROCK" : undefined,
prepareRuntimeAuth: async ({ apiKey, env }) =>
await resolveMantleRuntimeBearerToken({
apiKey,
env,
}),
createStreamFn: ({ model }) =>
model.api === "anthropic-messages" ? createMantleAnthropicStreamFn() : undefined,
matchesContextOverflowError: ({ errorMessage }) =>
/context_length_exceeded|max.*tokens.*exceeded/i.test(errorMessage),
classifyFailoverReason: ({ errorMessage }) => {
if (/rate_limit|too many requests|429/i.test(errorMessage)) {
return "rate_limit";
}
if (/overloaded|503|service.*unavailable/i.test(errorMessage)) {
return "overloaded";
}
return undefined;
},
});
}

View File

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