fix(amazon-bedrock): harden control-plane requests (#108756)

This commit is contained in:
Peter Steinberger
2026-07-16 01:13:16 -07:00
committed by GitHub
parent a7d5386896
commit b11d506c09
8 changed files with 466 additions and 171 deletions
+1
View File
@@ -280,6 +280,7 @@ Docs: https://docs.openclaw.ai
- **TUI abort diagnostics:** show sanitized tool argument-validation summaries for aborted runs in both Gateway and local TUI modes without exposing raw model arguments. (#91002) Thanks @wsyjh8.
- **iOS Watch replies:** persist queued quick replies in the gateway-scoped chat outbox and submit them through idempotent chat delivery, preventing losses, duplicates, and cross-gateway sends after reconnects. (#100031) Thanks @NianJiuZst.
- **iOS Gateway auth retry:** restrict stored device-token retry to parsed loopback hosts and reject wildcard bind addresses, preventing remote lookalike hostnames from receiving trusted retry credentials. (#99859) Thanks @ly85206559.
- **Amazon Bedrock control-plane deadlines:** bound model discovery and application inference-profile lookups, preserve caller cancellation, and close short-lived SDK clients after each request path. Thanks @Alix-007.
- **Bedrock Mantle discovery:** bound model-catalog fetch time and response size, and release rejected response bodies so stalled, oversized, or failed provider responses fall back safely. (#99961) Thanks @zhangguiping-xydt.
- **Discord thread-title prompts:** truncate generated-title message and channel context on UTF-16 boundaries so emoji cannot leave malformed model prompt text. (#101551) Thanks @Alix-007.
- **Task state migration:** canonicalize legacy `not-requested` delivery statuses during sidecar import and existing shared-database open so upgraded task registries and linked TaskFlows recover without manual SQL, and surface rejected persisted values in compact console diagnostics. (#103946) Thanks @bek91.
@@ -0,0 +1,120 @@
import { once } from "node:events";
import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
import {
BedrockClient,
GetInferenceProfileCommand,
ListFoundationModelsCommand,
} from "@aws-sdk/client-bedrock";
import { createDeferred } from "openclaw/plugin-sdk/extension-shared";
import { describe, expect, it, vi } from "vitest";
import { runBedrockControlPlaneRequest } from "./control-plane.js";
const transportCases: Array<{
operation: string;
expectedPath: string;
send: (client: BedrockClient, options: { abortSignal?: AbortSignal }) => Promise<unknown>;
}> = [
{
operation: "Bedrock ListFoundationModels",
expectedPath: "/foundation-models",
send: (client, options) => client.send(new ListFoundationModelsCommand({}), options),
},
{
operation: "Bedrock GetInferenceProfile",
expectedPath: "/inference-profiles/test-profile",
send: (client, options) =>
client.send(
new GetInferenceProfileCommand({ inferenceProfileIdentifier: "test-profile" }),
options,
),
},
];
describe("Bedrock control-plane transport", () => {
it("does not send when the parent signal is already aborted", async () => {
const controller = new AbortController();
const reason = new Error("cancelled before send");
controller.abort(reason);
const send = vi.fn(async () => "unexpected");
await expect(
runBedrockControlPlaneRequest({
operation: "Bedrock pre-aborted request",
signal: controller.signal,
send,
}),
).rejects.toBe(reason);
expect(send).not.toHaveBeenCalled();
});
it("rejects a transport response that resolves after the deadline", async () => {
vi.useFakeTimers();
try {
const response = createDeferred<string>();
const request = runBedrockControlPlaneRequest({
operation: "Bedrock late response",
send: () => response.promise,
});
await vi.advanceTimersByTimeAsync(30_000);
response.resolve("too late");
await expect(request).rejects.toMatchObject({ name: "TimeoutError" });
} finally {
vi.useRealTimers();
}
});
it.each(transportCases)(
"aborts and closes the real Smithy socket for $operation",
async (testCase) => {
const requestStarted = createDeferred<{
path: string | undefined;
socketClosed: Promise<void>;
}>();
const server = createServer((request) => {
requestStarted.resolve({
path: request.url,
socketClosed: new Promise<void>((resolve) => {
request.socket.once("close", () => {
resolve();
});
}),
});
});
server.listen(0, "127.0.0.1");
await once(server, "listening");
const address = server.address() as AddressInfo;
const client = new BedrockClient({
region: "us-east-1",
endpoint: `http://127.0.0.1:${address.port}`,
credentials: { accessKeyId: "test", secretAccessKey: "test" },
maxAttempts: 1,
});
const controller = new AbortController();
const reason = new Error("caller cancelled control-plane request");
try {
const response = runBedrockControlPlaneRequest({
operation: testCase.operation,
signal: controller.signal,
send: (options) => testCase.send(client, options),
});
const request = await requestStarted.promise;
expect(request.path).toBe(testCase.expectedPath);
controller.abort(reason);
await expect(response).rejects.toMatchObject({ name: "AbortError", cause: reason });
await request.socketClosed;
} finally {
client.destroy();
server.closeAllConnections();
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
}
},
);
});
@@ -0,0 +1,58 @@
/** Bedrock control-plane SDK loading and deadline-bound command dispatch. */
import type {
BedrockClient,
GetInferenceProfileCommand,
GetInferenceProfileCommandInput,
ListFoundationModelsCommand,
ListInferenceProfilesCommand,
ListInferenceProfilesCommandInput,
} from "@aws-sdk/client-bedrock";
import { buildTimeoutAbortSignal } from "openclaw/plugin-sdk/extension-shared";
const BEDROCK_CONTROL_PLANE_REQUEST_TIMEOUT_MS = 30_000;
export type BedrockControlPlaneSdk = {
createClient(region?: string): BedrockClient;
createGetInferenceProfileCommand(
input: GetInferenceProfileCommandInput,
): GetInferenceProfileCommand;
createListFoundationModelsCommand(): ListFoundationModelsCommand;
createListInferenceProfilesCommand(
input: ListInferenceProfilesCommandInput,
): ListInferenceProfilesCommand;
};
export async function loadBedrockControlPlaneSdk(): Promise<BedrockControlPlaneSdk> {
const {
BedrockClient,
GetInferenceProfileCommand,
ListFoundationModelsCommand,
ListInferenceProfilesCommand,
} = await import("@aws-sdk/client-bedrock");
return {
createClient: (region) => new BedrockClient(region ? { region } : {}),
createGetInferenceProfileCommand: (input) => new GetInferenceProfileCommand(input),
createListFoundationModelsCommand: () => new ListFoundationModelsCommand({}),
createListInferenceProfilesCommand: (input) => new ListInferenceProfilesCommand(input),
};
}
export async function runBedrockControlPlaneRequest<T>(params: {
operation: string;
signal?: AbortSignal;
send: (options: { abortSignal?: AbortSignal }) => Promise<T>;
}): Promise<T> {
const { signal, cleanup } = buildTimeoutAbortSignal({
timeoutMs: BEDROCK_CONTROL_PLANE_REQUEST_TIMEOUT_MS,
signal: params.signal,
operation: params.operation,
});
try {
signal?.throwIfAborted();
const response = await params.send({ abortSignal: signal });
signal?.throwIfAborted();
return response;
} finally {
cleanup();
}
}
@@ -1,59 +0,0 @@
/** Bedrock control-plane SDK loading and deadline-bound command dispatch. */
import type { BedrockClient } from "@aws-sdk/client-bedrock";
const BEDROCK_DISCOVERY_REQUEST_TIMEOUT_MS = 30_000;
export type BedrockDiscoverySdk = {
createClient(region: string): BedrockClient;
createListFoundationModelsCommand(): unknown;
createListInferenceProfilesCommand(input: { nextToken?: string }): unknown;
};
export async function loadBedrockDiscoverySdk(): Promise<BedrockDiscoverySdk> {
const { BedrockClient, ListFoundationModelsCommand, ListInferenceProfilesCommand } =
await import("@aws-sdk/client-bedrock");
return {
createClient: (region) => new BedrockClient({ region }),
createListFoundationModelsCommand: () => new ListFoundationModelsCommand({}),
createListInferenceProfilesCommand: (input) => new ListInferenceProfilesCommand(input),
};
}
export function createInjectedClientDiscoverySdk(): BedrockDiscoverySdk {
class ListFoundationModelsCommand {
constructor(readonly input: Record<string, unknown> = {}) {}
}
class ListInferenceProfilesCommand {
constructor(readonly input: Record<string, unknown> = {}) {}
}
return {
createClient() {
throw new Error("clientFactory is required for injected Bedrock discovery commands");
},
createListFoundationModelsCommand: () => new ListFoundationModelsCommand({}),
createListInferenceProfilesCommand: (input) => new ListInferenceProfilesCommand(input),
};
}
function createBedrockDiscoveryTimeoutError(operation: string): Error {
const error = new Error(`${operation} timed out after ${BEDROCK_DISCOVERY_REQUEST_TIMEOUT_MS}ms`);
error.name = "TimeoutError";
return error;
}
export async function sendBedrockDiscoveryCommand<T>(
client: BedrockClient,
command: unknown,
operation: string,
): Promise<T> {
const controller = new AbortController();
const timeout = setTimeout(() => {
controller.abort(createBedrockDiscoveryTimeoutError(operation));
}, BEDROCK_DISCOVERY_REQUEST_TIMEOUT_MS);
timeout.unref?.();
try {
return (await client.send(command as never, { abortSignal: controller.signal })) as T;
} finally {
clearTimeout(timeout);
}
}
+5 -1
View File
@@ -10,7 +10,8 @@ import {
} from "./api.js";
const sendMock = vi.fn();
const clientFactory = () => ({ send: sendMock }) as unknown as BedrockClient;
const destroyMock = vi.fn();
const clientFactory = () => ({ send: sendMock, destroy: destroyMock }) as unknown as BedrockClient;
const baseActiveAnthropicSummary = {
modelId: "anthropic.claude-3-7-sonnet-20250219-v1:0",
@@ -44,6 +45,7 @@ function expectModelFields(model: unknown, expected: Record<string, unknown>): v
describe("bedrock discovery", () => {
beforeEach(() => {
sendMock.mockClear();
destroyMock.mockClear();
resetBedrockDiscoveryCacheForTest();
});
@@ -105,6 +107,7 @@ describe("bedrock discovery", () => {
contextWindow: 200000,
maxTokens: 4096,
});
expect(destroyMock).toHaveBeenCalledTimes(1);
});
it("applies provider filter", async () => {
@@ -486,6 +489,7 @@ describe("bedrock discovery", () => {
expect(sendMock).toHaveBeenCalledTimes(2);
expect(abortSignals).toHaveLength(2);
expect(abortSignals.every((signal) => signal.aborted)).toBe(true);
expect(destroyMock).toHaveBeenCalledTimes(1);
} finally {
vi.useRealTimers();
}
+70 -68
View File
@@ -31,11 +31,10 @@ import {
} from "openclaw/plugin-sdk/string-coerce-runtime";
import { refreshAwsSharedConfigCacheForBedrock } from "./aws-credential-refresh.js";
import {
createInjectedClientDiscoverySdk,
loadBedrockDiscoverySdk,
sendBedrockDiscoveryCommand,
type BedrockDiscoverySdk,
} from "./discovery-sdk.js";
loadBedrockControlPlaneSdk,
runBedrockControlPlaneRequest,
type BedrockControlPlaneSdk,
} from "./control-plane.js";
import { resolveBedrockConfigApiKey } from "./discovery-shared.js";
import { resolveBedrockNativeThinkingLevelMap } from "./thinking-policy.js";
@@ -393,17 +392,17 @@ function resolveBaseModelId(profile: InferenceProfileSummary): string | undefine
*/
async function fetchInferenceProfileSummaries(
client: BedrockClient,
createListInferenceProfilesCommand: BedrockDiscoverySdk["createListInferenceProfilesCommand"],
createListInferenceProfilesCommand: BedrockControlPlaneSdk["createListInferenceProfilesCommand"],
): Promise<InferenceProfileSummary[]> {
try {
const profiles: InferenceProfileSummary[] = [];
let nextToken: string | undefined;
do {
const response = await sendBedrockDiscoveryCommand<ListInferenceProfilesCommandOutput>(
client,
createListInferenceProfilesCommand({ nextToken }) as never,
"Bedrock ListInferenceProfiles",
);
const command = createListInferenceProfilesCommand({ nextToken });
const response = await runBedrockControlPlaneRequest({
operation: "Bedrock ListInferenceProfiles",
send: (options) => client.send(command, options),
});
for (const summary of response.inferenceProfileSummaries ?? []) {
profiles.push(summary);
}
@@ -548,9 +547,7 @@ export async function discoverBedrockModels(params: {
}
}
const sdk = params.clientFactory
? createInjectedClientDiscoverySdk()
: await loadBedrockDiscoverySdk();
const sdk = await loadBedrockControlPlaneSdk();
const clientFactory = params.clientFactory ?? ((region: string) => sdk.createClient(region));
if (!params.clientFactory) {
await refreshAwsSharedConfigCacheForBedrock();
@@ -558,66 +555,71 @@ export async function discoverBedrockModels(params: {
const client = clientFactory(params.region);
const discoveryPromise = (async () => {
// Discover foundation models and inference profiles in parallel.
// Both API calls are independent, but we need the foundation model data
// to resolve inference profile capabilities — so we fetch in parallel,
// then build the lookup map before processing profiles.
const [foundationResponse, profileSummaries] = await Promise.all([
sendBedrockDiscoveryCommand<ListFoundationModelsCommandOutput>(
client,
sdk.createListFoundationModelsCommand() as never,
"Bedrock ListFoundationModels",
),
fetchInferenceProfileSummaries(client, (input) =>
sdk.createListInferenceProfilesCommand(input),
),
]);
try {
// Discover foundation models and inference profiles in parallel.
// Both API calls are independent, but we need the foundation model data
// to resolve inference profile capabilities — so we fetch in parallel,
// then build the lookup map before processing profiles.
const foundationCommand = sdk.createListFoundationModelsCommand();
const [foundationResponse, profileSummaries] = await Promise.all([
runBedrockControlPlaneRequest({
operation: "Bedrock ListFoundationModels",
send: (options) => client.send(foundationCommand, options),
}),
fetchInferenceProfileSummaries(client, (input) =>
sdk.createListInferenceProfilesCommand(input),
),
]);
const discovered: ModelDefinitionConfig[] = [];
const seenIds = new Set<string>();
const foundationModels = new Map<string, ModelDefinitionConfig>();
const discovered: ModelDefinitionConfig[] = [];
const seenIds = new Set<string>();
const foundationModels = new Map<string, ModelDefinitionConfig>();
// Foundation models first — build both the results list and the lookup map.
for (const summary of foundationResponse.modelSummaries ?? []) {
if (!shouldIncludeSummary(summary, providerFilter)) {
continue;
}
const def = toModelDefinition(summary, {
contextWindow: defaultContextWindow,
maxTokens: defaultMaxTokens,
});
discovered.push(def);
const normalizedId = normalizeLowercaseStringOrEmpty(def.id);
seenIds.add(normalizedId);
foundationModels.set(normalizedId, def);
}
// Merge inference profiles — inherit capabilities from foundation models.
const inferenceProfiles = resolveInferenceProfiles(
profileSummaries,
{ contextWindow: defaultContextWindow, maxTokens: defaultMaxTokens },
providerFilter,
foundationModels,
);
for (const profile of inferenceProfiles) {
const normalizedId = normalizeLowercaseStringOrEmpty(profile.id);
if (!seenIds.has(normalizedId)) {
discovered.push(profile);
// Foundation models first — build both the results list and the lookup map.
for (const summary of foundationResponse.modelSummaries ?? []) {
if (!shouldIncludeSummary(summary, providerFilter)) {
continue;
}
const def = toModelDefinition(summary, {
contextWindow: defaultContextWindow,
maxTokens: defaultMaxTokens,
});
discovered.push(def);
const normalizedId = normalizeLowercaseStringOrEmpty(def.id);
seenIds.add(normalizedId);
foundationModels.set(normalizedId, def);
}
}
// Sort: global cross-region profiles first (recommended for most users —
// better capacity, automatic failover, no data sovereignty constraints),
// then remaining profiles/models alphabetically.
return discovered.toSorted((a, b) => {
const aGlobal = a.id.startsWith("global.") ? 0 : 1;
const bGlobal = b.id.startsWith("global.") ? 0 : 1;
if (aGlobal !== bGlobal) {
return aGlobal - bGlobal;
// Merge inference profiles — inherit capabilities from foundation models.
const inferenceProfiles = resolveInferenceProfiles(
profileSummaries,
{ contextWindow: defaultContextWindow, maxTokens: defaultMaxTokens },
providerFilter,
foundationModels,
);
for (const profile of inferenceProfiles) {
const normalizedId = normalizeLowercaseStringOrEmpty(profile.id);
if (!seenIds.has(normalizedId)) {
discovered.push(profile);
seenIds.add(normalizedId);
}
}
return a.name.localeCompare(b.name);
});
// Sort: global cross-region profiles first (recommended for most users —
// better capacity, automatic failover, no data sovereignty constraints),
// then remaining profiles/models alphabetically.
return discovered.toSorted((a, b) => {
const aGlobal = a.id.startsWith("global.") ? 0 : 1;
const bGlobal = b.id.startsWith("global.") ? 0 : 1;
if (aGlobal !== bGlobal) {
return aGlobal - bGlobal;
}
return a.name.localeCompare(b.name);
});
} finally {
// Discovery owns the short-lived control-plane client and its socket agents.
client.destroy();
}
})();
if (refreshIntervalSeconds > 0) {
+188 -35
View File
@@ -4,6 +4,7 @@ import { resolve } from "node:path";
import { expectDefined } from "@openclaw/normalization-core";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import type { PluginRuntime } from "openclaw/plugin-sdk/core";
import { createDeferred } from "openclaw/plugin-sdk/extension-shared";
import {
buildPluginApi,
registerSingleProviderPlugin,
@@ -20,48 +21,70 @@ type BedrockClientResult =
modelSummaries?: Array<Record<string, unknown>>;
inferenceProfileSummaries?: Array<Record<string, unknown>>;
}
| { stall: (signal: AbortSignal) => void }
| Error;
const foundationModelResults: BedrockClientResult[] = [];
const inferenceProfileListResults: BedrockClientResult[] = [];
const inferenceProfileGetResults: BedrockClientResult[] = [];
const bedrockClientConfigs: Array<Record<string, unknown>> = [];
const destroyBedrockClient = vi.fn();
const refreshSharedConfigCache = vi.fn(async () => {});
const sendBedrockCommand = vi.fn(async (command: unknown) => {
const commandName = command?.constructor?.name;
const queue =
commandName === "ListFoundationModelsCommand"
? foundationModelResults
: commandName === "ListInferenceProfilesCommand"
? inferenceProfileListResults
: inferenceProfileGetResults;
const next = queue.shift();
if (next instanceof Error) {
throw next;
}
if (next) {
return next;
}
if (commandName === "ListFoundationModelsCommand") {
return {
modelSummaries: [
{
modelId: NON_ANTHROPIC_MODEL,
modelName: "Nova Micro",
providerName: "Amazon",
inputModalities: ["TEXT"],
outputModalities: ["TEXT"],
responseStreamingSupported: true,
modelLifecycle: { status: "ACTIVE" },
},
],
};
}
if (commandName === "ListInferenceProfilesCommand") {
return { inferenceProfileSummaries: [] };
}
return { models: [] };
});
const sendBedrockCommand = vi.fn(
async (command: unknown, options?: { abortSignal?: AbortSignal }) => {
const commandName = command?.constructor?.name;
const queue =
commandName === "ListFoundationModelsCommand"
? foundationModelResults
: commandName === "ListInferenceProfilesCommand"
? inferenceProfileListResults
: inferenceProfileGetResults;
const next = queue.shift();
if (next instanceof Error) {
throw next;
}
if (next && "stall" in next) {
const signal = options?.abortSignal;
if (!signal) {
throw new Error("expected Bedrock control-plane abort signal");
}
next.stall(signal);
return await new Promise<never>((_resolve, reject) => {
const rejectFromSignal = () => {
const reason = signal.reason;
reject(reason instanceof Error ? reason : new Error("Bedrock request aborted"));
};
if (signal.aborted) {
rejectFromSignal();
} else {
signal.addEventListener("abort", rejectFromSignal, { once: true });
}
});
}
if (next) {
return next;
}
if (commandName === "ListFoundationModelsCommand") {
return {
modelSummaries: [
{
modelId: NON_ANTHROPIC_MODEL,
modelName: "Nova Micro",
providerName: "Amazon",
inputModalities: ["TEXT"],
outputModalities: ["TEXT"],
responseStreamingSupported: true,
modelLifecycle: { status: "ACTIVE" },
},
],
};
}
if (commandName === "ListInferenceProfilesCommand") {
return { inferenceProfileSummaries: [] };
}
return { models: [] };
},
);
vi.mock("@aws-sdk/client-bedrock", () => {
class GetInferenceProfileCommand {
@@ -82,6 +105,7 @@ vi.mock("@aws-sdk/client-bedrock", () => {
}
send = sendBedrockCommand;
destroy = destroyBedrockClient;
}
return {
@@ -268,6 +292,7 @@ describe("amazon-bedrock provider plugin", () => {
inferenceProfileListResults.length = 0;
inferenceProfileGetResults.length = 0;
bedrockClientConfigs.length = 0;
destroyBedrockClient.mockClear();
refreshSharedConfigCache.mockClear();
sendBedrockCommand.mockClear();
resetBedrockDiscoveryCacheForTest();
@@ -1454,6 +1479,8 @@ describe("amazon-bedrock provider plugin", () => {
expect(system[1]).toEqual({ cachePoint: { type: "default" } });
expect(sendBedrockCommand).toHaveBeenCalledTimes(1);
expect(bedrockClientConfigs).toEqual([{ region: "us-east-1" }]);
expect(refreshSharedConfigCache).toHaveBeenCalledTimes(1);
expect(destroyBedrockClient).toHaveBeenCalledTimes(1);
});
it("omits temperature for opaque application inference profile ARNs that resolve to Opus 4.7", async () => {
@@ -1591,6 +1618,132 @@ describe("amazon-bedrock provider plugin", () => {
{ cachePoint: { type: "default" } },
]);
expect(sendBedrockCommand).toHaveBeenCalledTimes(2);
expect(destroyBedrockClient).toHaveBeenCalledTimes(2);
});
it("times out stalled profile lookup, destroys its client, and retries next request", async () => {
vi.useFakeTimers();
try {
const modelId =
"arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/timeout-retry";
const firstStarted = createDeferred<AbortSignal>();
inferenceProfileGetResults.push(
{ stall: firstStarted.resolve },
{
models: [
{
modelArn:
"arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-sonnet-4-6-20250514-v1:0",
},
],
},
);
const provider = await registerWithConfig(undefined);
const firstPayload: Record<string, unknown> = {
system: [{ text: "You are helpful." }],
messages: [{ role: "user", content: [{ text: "Hello" }] }],
};
const secondPayload: Record<string, unknown> = {
system: [{ text: "You are helpful." }],
messages: [{ role: "user", content: [{ text: "Hello again" }] }],
};
const firstRequest = callWrappedStreamWithPayload(
provider,
modelId,
makeAppInferenceProfileDescriptor(modelId),
{ cacheRetention: "short" },
firstPayload,
);
const firstSignal = await firstStarted.promise;
await vi.advanceTimersByTimeAsync(30_000);
await expect(firstRequest).resolves.toBe(firstPayload);
expect(firstSignal.aborted).toBe(true);
expect(firstSignal.reason).toMatchObject({ name: "TimeoutError" });
expect(firstPayload.system).toEqual([{ text: "You are helpful." }]);
expect(destroyBedrockClient).toHaveBeenCalledTimes(1);
await callWrappedStreamWithPayload(
provider,
modelId,
makeAppInferenceProfileDescriptor(modelId),
{ cacheRetention: "short" },
secondPayload,
);
expect(secondPayload.system).toEqual([
{ text: "You are helpful." },
{ cachePoint: { type: "default" } },
]);
expect(sendBedrockCommand).toHaveBeenCalledTimes(2);
expect(refreshSharedConfigCache).toHaveBeenCalledTimes(2);
expect(destroyBedrockClient).toHaveBeenCalledTimes(2);
} finally {
vi.useRealTimers();
}
});
it("preserves caller cancellation across both profile lookup paths", async () => {
const cases = [
"arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/opaque-abort",
"arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/claude-abort",
];
for (const [index, modelId] of cases.entries()) {
const started = createDeferred<AbortSignal>();
inferenceProfileGetResults.push({ stall: started.resolve });
const provider = await registerWithConfig(undefined);
const controller = new AbortController();
const reason = new Error(`caller cancelled ${index}`);
const payload: Record<string, unknown> = {
inferenceConfig: { temperature: 0.3 },
system: [{ text: "You are helpful." }],
messages: [{ role: "user", content: [{ text: "Hello" }] }],
};
const request = callWrappedStreamWithPayload(
provider,
modelId,
makeAppInferenceProfileDescriptor(modelId),
{ cacheRetention: "short", temperature: 0.3, signal: controller.signal },
payload,
);
await started.promise;
controller.abort(reason);
await expect(request).rejects.toBe(reason);
}
expect(sendBedrockCommand).toHaveBeenCalledTimes(2);
expect(refreshSharedConfigCache).toHaveBeenCalledTimes(2);
expect(destroyBedrockClient).toHaveBeenCalledTimes(2);
});
it("checks caller cancellation before refreshing AWS credentials", async () => {
const modelId =
"arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/pre-aborted";
const controller = new AbortController();
const reason = new Error("cancelled before payload");
controller.abort(reason);
const provider = await registerWithConfig(undefined);
await expect(
callWrappedStreamWithPayload(
provider,
modelId,
makeAppInferenceProfileDescriptor(modelId),
{ cacheRetention: "short", signal: controller.signal },
{
system: [{ text: "You are helpful." }],
messages: [{ role: "user", content: [{ text: "Hello" }] }],
},
),
).rejects.toBe(reason);
expect(refreshSharedConfigCache).not.toHaveBeenCalled();
expect(sendBedrockCommand).not.toHaveBeenCalled();
expect(destroyBedrockClient).not.toHaveBeenCalled();
});
});
});
@@ -2,6 +2,7 @@
* Synchronous Amazon Bedrock provider registration. It wires Bedrock streaming,
* model discovery, thinking policy, guardrails, and embedding integration.
*/
import type { BedrockClient } from "@aws-sdk/client-bedrock";
import type { StreamFn } from "openclaw/plugin-sdk/agent-core";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { registerApiProvider, streamSimple } from "openclaw/plugin-sdk/llm";
@@ -21,6 +22,7 @@ import {
import { streamWithPayloadPatch } from "openclaw/plugin-sdk/provider-stream-shared";
import { refreshAwsSharedConfigCacheForBedrock } from "./aws-credential-refresh.js";
import { supportsBedrockPromptCaching } from "./bedrock-options.js";
import { loadBedrockControlPlaneSdk, runBedrockControlPlaneRequest } from "./control-plane.js";
import { mergeImplicitBedrockProvider, resolveBedrockConfigApiKey } from "./discovery-shared.js";
import { bedrockMemoryEmbeddingProviderAdapter } from "./memory-embedding-adapter.js";
import { streamBedrock, streamSimpleBedrock } from "./stream.runtime.js";
@@ -250,19 +252,26 @@ const appProfileTraitsCache = new Map<string, BedrockAppProfileTraits>();
async function resolveAppProfileTraits(
modelId: string,
fallbackRegion: string | undefined,
signal: AbortSignal | undefined,
): Promise<BedrockAppProfileTraits> {
const cached = appProfileTraitsCache.get(modelId);
if (cached) {
return cached;
}
let client: BedrockClient | undefined;
try {
signal?.throwIfAborted();
const region = extractRegionFromArn(modelId) ?? fallbackRegion;
await refreshAwsSharedConfigCacheForBedrock();
const { BedrockClient, GetInferenceProfileCommand } = await import("@aws-sdk/client-bedrock");
const client = new BedrockClient(region ? { region } : {});
const resp = await client.send(
new GetInferenceProfileCommand({ inferenceProfileIdentifier: modelId }),
);
const sdk = await loadBedrockControlPlaneSdk();
signal?.throwIfAborted();
const controlPlaneClient = sdk.createClient(region);
client = controlPlaneClient;
const command = sdk.createGetInferenceProfileCommand({ inferenceProfileIdentifier: modelId });
const resp = await runBedrockControlPlaneRequest({
operation: "Bedrock GetInferenceProfile",
signal,
send: (options) => controlPlaneClient.send(command, options),
});
const models = resp.models ?? [];
const modelArns = models.map((model) => model.modelArn ?? "");
const traits = {
@@ -273,12 +282,16 @@ async function resolveAppProfileTraits(
appProfileTraitsCache.set(modelId, traits);
return traits;
} catch {
// Caller cancellation is terminal; only provider/control-plane failures use heuristics.
signal?.throwIfAborted();
// Transient failures (throttling, network, IAM) should not be cached —
// return the heuristic fallback but allow retry on the next request.
return {
cacheEligible: isAnthropicBedrockModel(modelId),
omitTemperature: isOpus47OrNewerBedrockModelRef(modelId),
};
} finally {
client?.destroy();
}
}
@@ -448,7 +461,10 @@ export function registerAmazonBedrockPlugin(api: OpenClawPluginApi): void {
return {
...options,
onPayload: async (payload: unknown, payloadModel: unknown) => {
const signal = (options as { signal?: AbortSignal }).signal;
signal?.throwIfAborted();
await refreshAwsSharedConfigCacheForBedrock();
signal?.throwIfAborted();
return originalOnPayload?.(payload, payloadModel);
},
};
@@ -652,7 +668,7 @@ export function registerAmazonBedrockPlugin(api: OpenClawPluginApi): void {
if (shouldOmitTemperature) {
omitUnsupportedClaudePayloadTemperature(payloadRecord);
} else if (mayNeedTemperatureTrait) {
const traits = await resolveAppProfileTraits(modelId, region);
const traits = await resolveAppProfileTraits(modelId, region, merged.signal);
if (traits.omitTemperature) {
omitUnsupportedClaudePayloadTemperature(payloadRecord);
}
@@ -672,7 +688,7 @@ export function registerAmazonBedrockPlugin(api: OpenClawPluginApi): void {
withAwsCredentialRefreshOnPayload({
...merged,
onPayload: async (payload: unknown, payloadModel: unknown) => {
const traits = await resolveAppProfileTraits(modelId, region);
const traits = await resolveAppProfileTraits(modelId, region, merged.signal);
if (payload && typeof payload === "object") {
const payloadRecord = payload as Record<string, unknown>;
if (traits.cacheEligible) {