fix(amazon-bedrock-mantle): align runtime deps

This commit is contained in:
Peter Steinberger
2026-04-23 00:43:02 +01:00
parent d50181e209
commit c65b232463
8 changed files with 181 additions and 84 deletions

View File

@@ -1,3 +1,7 @@
{
"specs": ["@aws/bedrock-token-generator@^1.1.0"]
"specs": [
"@anthropic-ai/sdk@0.81.0",
"@aws/bedrock-token-generator@^1.1.0",
"@mariozechner/pi-ai@0.68.1"
]
}

View File

@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
const {
discoverMantleModels,
generateBearerTokenFromIam,
getCachedIamToken,
@@ -7,18 +8,14 @@ import {
mergeImplicitMantleProvider,
resetIamTokenCacheForTest,
resetMantleDiscoveryCacheForTest,
resolveMantleBearerToken,
resolveImplicitMantleProvider,
resolveMantleBearerToken,
resolveMantleRuntimeBearerToken,
} from "./api.js";
} = await import("./api.js");
const mocks = vi.hoisted(() => ({
getTokenProvider: vi.fn(),
}));
vi.mock("@aws/bedrock-token-generator", () => ({
getTokenProvider: mocks.getTokenProvider,
}));
function createTokenProviderFactory(tokenProvider: () => Promise<string>) {
return vi.fn(() => tokenProvider);
}
describe("bedrock mantle discovery", () => {
const originalEnv = process.env;
@@ -26,7 +23,6 @@ describe("bedrock mantle discovery", () => {
beforeEach(() => {
process.env = { ...originalEnv };
vi.restoreAllMocks();
mocks.getTokenProvider.mockReset();
resetMantleDiscoveryCacheForTest();
resetIamTokenCacheForTest();
});
@@ -65,12 +61,15 @@ describe("bedrock mantle discovery", () => {
it("generates token from IAM credentials when token generation succeeds", async () => {
const tokenProvider = vi.fn(async () => "bedrock-api-key-generated"); // pragma: allowlist secret
mocks.getTokenProvider.mockReturnValue(tokenProvider);
const tokenProviderFactory = createTokenProviderFactory(tokenProvider);
const token = await generateBearerTokenFromIam({ region: "us-east-1" });
const token = await generateBearerTokenFromIam({
region: "us-east-1",
tokenProviderFactory,
});
expect(token).toBe("bedrock-api-key-generated");
expect(mocks.getTokenProvider).toHaveBeenCalledWith({
expect(tokenProviderFactory).toHaveBeenCalledWith({
region: "us-east-1",
expiresInSeconds: 7200,
});
@@ -79,12 +78,20 @@ describe("bedrock mantle discovery", () => {
it("caches generated IAM tokens within TTL", async () => {
const tokenProvider = vi.fn(async () => "bedrock-api-key-cached"); // pragma: allowlist secret
mocks.getTokenProvider.mockReturnValue(tokenProvider);
const tokenProviderFactory = createTokenProviderFactory(tokenProvider);
let now = 1000;
const t1 = await generateBearerTokenFromIam({ region: "us-east-1", now: () => now });
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 });
const t2 = await generateBearerTokenFromIam({
region: "us-east-1",
now: () => now,
tokenProviderFactory,
});
expect(t1).toEqual(t2);
expect(tokenProvider).toHaveBeenCalledTimes(1);
@@ -95,18 +102,26 @@ describe("bedrock mantle discovery", () => {
.fn<() => Promise<string>>()
.mockResolvedValueOnce("bedrock-api-key-east") // pragma: allowlist secret
.mockResolvedValueOnce("bedrock-api-key-west"); // pragma: allowlist secret
mocks.getTokenProvider.mockReturnValue(tokenProvider);
const tokenProviderFactory = createTokenProviderFactory(tokenProvider);
const east = await generateBearerTokenFromIam({ region: "us-east-1", now: () => 1000 });
const west = await generateBearerTokenFromIam({ region: "us-west-2", now: () => 2000 });
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(mocks.getTokenProvider).toHaveBeenNthCalledWith(1, {
expect(tokenProviderFactory).toHaveBeenNthCalledWith(1, {
region: "us-east-1",
expiresInSeconds: 7200,
});
expect(mocks.getTokenProvider).toHaveBeenNthCalledWith(2, {
expect(tokenProviderFactory).toHaveBeenNthCalledWith(2, {
region: "us-west-2",
expiresInSeconds: 7200,
});
@@ -114,19 +129,21 @@ describe("bedrock mantle discovery", () => {
});
it("returns undefined when IAM token generation fails", async () => {
mocks.getTokenProvider.mockImplementation(() => {
const tokenProviderFactory = vi.fn(() => {
throw new Error("no credentials");
});
await expect(generateBearerTokenFromIam({ region: "us-east-1" })).resolves.toBeUndefined();
await expect(
generateBearerTokenFromIam({ region: "us-east-1", tokenProviderFactory }),
).resolves.toBeUndefined();
});
it("getCachedIamToken returns cached token when valid", async () => {
const tokenProvider = vi.fn(async () => "bedrock-cached-token"); // pragma: allowlist secret
mocks.getTokenProvider.mockReturnValue(tokenProvider);
const tokenProviderFactory = createTokenProviderFactory(tokenProvider);
// Generate a token to populate the cache
await generateBearerTokenFromIam({ region: "us-east-1" });
await generateBearerTokenFromIam({ region: "us-east-1", tokenProviderFactory });
// Sync read should return the cached token
expect(getCachedIamToken("us-east-1")).toBe("bedrock-cached-token");
@@ -138,10 +155,14 @@ describe("bedrock mantle discovery", () => {
it("getCachedIamToken returns undefined when cache is expired", async () => {
const tokenProvider = vi.fn(async () => "bedrock-expired-token"); // pragma: allowlist secret
mocks.getTokenProvider.mockReturnValue(tokenProvider);
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 });
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
@@ -380,22 +401,25 @@ describe("bedrock mantle discovery", () => {
expect(provider?.auth).toBe("api-key");
expect(provider?.apiKey).toBe("env:AWS_BEARER_TOKEN_BEDROCK");
expect(provider?.models).toHaveLength(2);
expect(provider?.models?.find((model) => model.id === "anthropic.claude-opus-4-7")).toMatchObject(
{
api: "anthropic-messages",
baseUrl: "https://bedrock-mantle.us-east-1.api.aws/anthropic",
reasoning: false,
},
);
expect(
provider?.models?.find((model) => model.id === "anthropic.claude-opus-4-7"),
).toMatchObject({
api: "anthropic-messages",
reasoning: false,
});
expect(
provider?.models?.find((model) => model.id === "anthropic.claude-opus-4-7"),
).not.toHaveProperty("baseUrl");
});
it("returns null when no auth is available", async () => {
mocks.getTokenProvider.mockImplementation(() => {
const tokenProviderFactory = vi.fn(() => {
throw new Error("no credentials");
});
const provider = await resolveImplicitMantleProvider({
env: {} as NodeJS.ProcessEnv,
tokenProviderFactory,
});
expect(provider).toBeNull();
@@ -403,13 +427,13 @@ describe("bedrock mantle discovery", () => {
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" }],
}),
});
mocks.getTokenProvider.mockReturnValue(tokenProvider);
const provider = await resolveImplicitMantleProvider({
env: {
@@ -417,6 +441,7 @@ describe("bedrock mantle discovery", () => {
AWS_REGION: "us-east-1",
} as NodeJS.ProcessEnv,
fetchFn: mockFetch as unknown as typeof fetch,
tokenProviderFactory,
});
expect(provider).not.toBeNull();
@@ -434,11 +459,12 @@ describe("bedrock mantle discovery", () => {
it("resolves Mantle runtime auth from the cached IAM token marker", async () => {
const tokenProvider = vi.fn(async () => "bedrock-api-key-runtime"); // pragma: allowlist secret
mocks.getTokenProvider.mockReturnValue(tokenProvider);
const tokenProviderFactory = createTokenProviderFactory(tokenProvider);
await generateBearerTokenFromIam({
region: "us-east-1",
now: () => 1000,
tokenProviderFactory,
});
await expect(
@@ -448,6 +474,7 @@ describe("bedrock mantle discovery", () => {
AWS_REGION: "us-east-1",
} as NodeJS.ProcessEnv,
now: () => 2000,
tokenProviderFactory,
}),
).resolves.toMatchObject({
apiKey: "bedrock-api-key-runtime",
@@ -458,7 +485,7 @@ describe("bedrock mantle discovery", () => {
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
mocks.getTokenProvider.mockReturnValue(tokenProvider);
const tokenProviderFactory = createTokenProviderFactory(tokenProvider);
await expect(
resolveMantleRuntimeBearerToken({
@@ -467,6 +494,7 @@ describe("bedrock mantle discovery", () => {
AWS_REGION: "us-east-1",
} as NodeJS.ProcessEnv,
now: () => 5000,
tokenProviderFactory,
}),
).resolves.toMatchObject({
apiKey: "bedrock-api-key-fresh",

View File

@@ -43,10 +43,6 @@ function mantleEndpoint(region: string): string {
return `https://bedrock-mantle.${region}.api.aws`;
}
function mantleAnthropicBaseUrl(region: string): string {
return `https://bedrock-mantle.${region}.api.aws/anthropic`;
}
function isSupportedRegion(region: string): boolean {
return (MANTLE_SUPPORTED_REGIONS as readonly string[]).includes(region);
}
@@ -56,6 +52,17 @@ function isSupportedRegion(region: string): boolean {
// ---------------------------------------------------------------------------
export type MantleBearerTokenProvider = () => Promise<string>;
export 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.
@@ -100,6 +107,7 @@ function getCachedIamTokenEntry(
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);
@@ -109,12 +117,8 @@ export async function generateBearerTokenFromIam(params: {
}
try {
const { getTokenProvider } = (await import("@aws/bedrock-token-generator")) as {
getTokenProvider: (opts?: {
region?: string;
expiresInSeconds?: number;
}) => () => Promise<string>;
};
const getTokenProvider =
params.tokenProviderFactory ?? (await loadMantleBearerTokenProviderFactory());
const token = await getTokenProvider({
region: params.region,
expiresInSeconds: 7200, // 2 hours
@@ -144,6 +148,7 @@ 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 };
@@ -160,6 +165,7 @@ export async function resolveMantleRuntimeBearerToken(params: {
const token = await generateBearerTokenFromIam({
region,
now: params.now,
tokenProviderFactory: params.tokenProviderFactory,
});
if (!token) {
return undefined;
@@ -317,6 +323,7 @@ export async function discoverMantleModels(params: {
export async function resolveImplicitMantleProvider(params: {
env?: NodeJS.ProcessEnv;
fetchFn?: typeof fetch;
tokenProviderFactory?: MantleBearerTokenProviderFactory;
}): Promise<ModelProviderConfig | null> {
const env = params.env ?? process.env;
const region = resolveMantleRegion(env);
@@ -328,7 +335,12 @@ export async function resolveImplicitMantleProvider(params: {
}
// Try explicit token first, then generate from IAM credentials
const bearerToken = explicitBearerToken ?? (await generateBearerTokenFromIam({ region }));
const bearerToken =
explicitBearerToken ??
(await generateBearerTokenFromIam({
region,
tokenProviderFactory: params.tokenProviderFactory,
}));
if (!bearerToken) {
return null;
@@ -350,13 +362,11 @@ export async function resolveImplicitMantleProvider(params: {
// 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 anthropicBaseUrl = mantleAnthropicBaseUrl(region);
const claudeModels: ModelDefinitionConfig[] = [
{
id: "anthropic.claude-opus-4-7",
name: "Claude Opus 4.7",
api: "anthropic-messages" as const,
baseUrl: anthropicBaseUrl,
reasoning: false,
input: ["text", "image"],
cost: {

View File

@@ -1,22 +1,9 @@
import type { Api, Model } from "@mariozechner/pi-ai";
import { describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
AnthropicConstructor: vi.fn(function MockAnthropic(options: unknown) {
return { options };
}),
streamAnthropic: vi.fn(),
}));
vi.mock("@anthropic-ai/sdk", () => ({
default: mocks.AnthropicConstructor,
}));
vi.mock("@mariozechner/pi-ai/anthropic", () => ({
streamAnthropic: mocks.streamAnthropic,
}));
import { createMantleAnthropicStreamFn } from "./mantle-anthropic.runtime.js";
import {
createMantleAnthropicStreamFn,
resolveMantleAnthropicBaseUrl,
} from "./mantle-anthropic.runtime.js";
function createTestModel(): Model<Api> {
return {
@@ -24,7 +11,7 @@ function createTestModel(): Model<Api> {
name: "Claude Opus 4.7",
provider: "amazon-bedrock-mantle",
api: "anthropic-messages",
baseUrl: "https://bedrock-mantle.us-east-1.api.aws/anthropic",
baseUrl: "https://bedrock-mantle.us-east-1.api.aws/v1",
headers: {
"X-Test": "model-header",
},
@@ -36,14 +23,22 @@ function createTestModel(): Model<Api> {
} as Model<Api>;
}
function createTestDeps() {
return {
createClient: vi.fn((options: unknown) => ({ options }) as never),
stream: vi.fn(),
};
}
describe("createMantleAnthropicStreamFn", () => {
it("uses authToken bearer auth for Mantle Anthropic requests", () => {
const stream = { kind: "anthropic-stream" };
const model = createTestModel();
const context = { messages: [] };
mocks.streamAnthropic.mockReturnValue(stream);
const deps = createTestDeps();
deps.stream.mockReturnValue(stream as never);
const result = createMantleAnthropicStreamFn()(model, context, {
const result = createMantleAnthropicStreamFn(deps)(model, context, {
apiKey: "bedrock-bearer-token",
headers: {
"X-Caller": "caller-header",
@@ -51,7 +46,7 @@ describe("createMantleAnthropicStreamFn", () => {
});
expect(result).toBe(stream);
expect(mocks.AnthropicConstructor).toHaveBeenCalledWith(
expect(deps.createClient).toHaveBeenCalledWith(
expect.objectContaining({
apiKey: null,
authToken: "bedrock-bearer-token",
@@ -64,7 +59,7 @@ describe("createMantleAnthropicStreamFn", () => {
}),
}),
);
expect(mocks.streamAnthropic).toHaveBeenCalledWith(
expect(deps.stream).toHaveBeenCalledWith(
model,
context,
expect.objectContaining({
@@ -81,15 +76,16 @@ describe("createMantleAnthropicStreamFn", () => {
it("omits unsupported Opus 4.7 sampling and reasoning overrides", () => {
const model = createTestModel();
const context = { messages: [] };
mocks.streamAnthropic.mockReturnValue({ kind: "anthropic-stream" });
const deps = createTestDeps();
deps.stream.mockReturnValue({ kind: "anthropic-stream" } as never);
void createMantleAnthropicStreamFn()(model, context, {
void createMantleAnthropicStreamFn(deps)(model, context, {
apiKey: "bedrock-bearer-token",
temperature: 0.2,
reasoning: "high",
});
expect(mocks.streamAnthropic).toHaveBeenCalledWith(
expect(deps.stream).toHaveBeenCalledWith(
model,
context,
expect.objectContaining({
@@ -98,4 +94,13 @@ describe("createMantleAnthropicStreamFn", () => {
}),
);
});
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

@@ -4,6 +4,18 @@ import type { Api, Model, SimpleStreamOptions } from "@mariozechner/pi-ai";
import { streamAnthropic } from "@mariozechner/pi-ai/anthropic";
const MANTLE_ANTHROPIC_BETA = "fine-grained-tool-streaming-2025-05-14";
type AnthropicOptions = ConstructorParameters<typeof Anthropic>[0];
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");
@@ -62,13 +74,18 @@ function adjustMaxTokensForThinking(
return { maxTokens, thinkingBudget };
}
export function createMantleAnthropicStreamFn(): StreamFn {
export function createMantleAnthropicStreamFn(deps?: {
createClient?: (options: AnthropicOptions) => Anthropic;
stream?: typeof streamAnthropic;
}): StreamFn {
return (model, context, options) => {
const apiKey = options?.apiKey ?? "";
const client = new Anthropic({
const createClient = deps?.createClient ?? ((clientOptions) => new Anthropic(clientOptions));
const stream = deps?.stream ?? streamAnthropic;
const client = createClient({
apiKey: null,
authToken: apiKey,
baseURL: model.baseUrl,
baseURL: resolveMantleAnthropicBaseUrl(model.baseUrl),
dangerouslyAllowBrowser: true,
defaultHeaders: mergeHeaders(
{
@@ -82,7 +99,7 @@ export function createMantleAnthropicStreamFn(): StreamFn {
});
const base = buildMantleAnthropicBaseOptions(model, options, apiKey);
if (!options?.reasoning || requiresDefaultSampling(model.id)) {
return streamAnthropic(model as Model<"anthropic-messages">, context, {
return stream(model as Model<"anthropic-messages">, context, {
...base,
client,
thinkingEnabled: false,
@@ -95,7 +112,7 @@ export function createMantleAnthropicStreamFn(): StreamFn {
options.reasoning,
options.thinkingBudgets,
);
return streamAnthropic(model as Model<"anthropic-messages">, context, {
return stream(model as Model<"anthropic-messages">, context, {
...base,
client,
maxTokens: adjusted.maxTokens,

View File

@@ -5,7 +5,9 @@
"description": "OpenClaw Amazon Bedrock Mantle (OpenAI-compatible) provider plugin",
"type": "module",
"dependencies": {
"@aws/bedrock-token-generator": "^1.1.0"
"@anthropic-ai/sdk": "0.81.0",
"@aws/bedrock-token-generator": "^1.1.0",
"@mariozechner/pi-ai": "0.68.1"
},
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*"

6
pnpm-lock.yaml generated
View File

@@ -272,9 +272,15 @@ importers:
extensions/amazon-bedrock-mantle:
dependencies:
'@anthropic-ai/sdk':
specifier: 0.81.0
version: 0.81.0(zod@4.3.6)
'@aws/bedrock-token-generator':
specifier: ^1.1.0
version: 1.1.0
'@mariozechner/pi-ai':
specifier: 0.68.1
version: 0.68.1(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)
devDependencies:
'@openclaw/plugin-sdk':
specifier: workspace:*

View File

@@ -41,6 +41,21 @@ function shouldStartGatewayMemoryBackend(cfg: OpenClawConfig): boolean {
return cfg.memory?.backend === "qmd";
}
function isConfiguredCliBackendPrimary(params: {
cfg: OpenClawConfig;
explicitPrimary: string;
normalizeProviderId: (provider: string) => string;
}): boolean {
const slashIndex = params.explicitPrimary.indexOf("/");
if (slashIndex <= 0) {
return false;
}
const provider = params.normalizeProviderId(params.explicitPrimary.slice(0, slashIndex));
return Object.keys(params.cfg.agents?.defaults?.cliBackends ?? {}).some(
(backend) => params.normalizeProviderId(backend) === provider,
);
}
async function hasGatewayStartupInternalHookListeners(): Promise<boolean> {
const { hasInternalHookListeners } = await import("../hooks/internal-hooks.js");
return hasInternalHookListeners("gateway", "startup");
@@ -55,6 +70,16 @@ async function prewarmConfiguredPrimaryModel(params: {
if (!explicitPrimary) {
return;
}
const { normalizeProviderId } = await import("../agents/provider-id.js");
if (
isConfiguredCliBackendPrimary({
cfg: params.cfg,
explicitPrimary,
normalizeProviderId,
})
) {
return;
}
const [
{ resolveOpenClawAgentDir },
{ DEFAULT_MODEL, DEFAULT_PROVIDER },