mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
fix(amazon-bedrock): harden control-plane requests (#108756)
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user