fix(bedrock): bound Mantle model discovery fetches (#99961)

* fix(bedrock): bound Mantle model discovery fetches

* fix(bedrock): release rejected Mantle responses

* docs(changelog): position Bedrock Mantle fix

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
xingzhou
2026-07-06 02:21:00 +01:00
committed by GitHub
co-authored by Peter Steinberger
parent f1a374a73a
commit a77ea59ce7
3 changed files with 106 additions and 38 deletions
+1
View File
@@ -72,6 +72,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.
- **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.
## 2026.7.1
@@ -60,6 +60,14 @@ function recordField(value: unknown, field: string): Record<string, unknown> {
return value as Record<string, unknown>;
}
function modelDiscoveryResponse(body: unknown, init?: ResponseInit): Response {
const headers = new Headers(init?.headers);
if (!headers.has("content-type")) {
headers.set("content-type", "application/json");
}
return new Response(JSON.stringify(body), { ...init, headers });
}
describe("bedrock mantle discovery", () => {
const originalEnv = process.env;
@@ -262,16 +270,15 @@ describe("bedrock mantle discovery", () => {
// ---------------------------------------------------------------------------
it("discovers models from Mantle /v1/models endpoint sorted by id", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
const mockFetch = vi.fn().mockResolvedValue(
modelDiscoveryResponse({
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",
@@ -298,9 +305,8 @@ describe("bedrock mantle discovery", () => {
});
it("infers reasoning support from model IDs", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
const mockFetch = vi.fn().mockResolvedValue(
modelDiscoveryResponse({
data: [
{ id: "moonshotai.kimi-k2-thinking", object: "model" },
{ id: "openai.gpt-oss-120b", object: "model" },
@@ -309,7 +315,7 @@ describe("bedrock mantle discovery", () => {
{ id: "mistral.mistral-large-3-675b-instruct", object: "model" },
],
}),
});
);
const models = await discoverMantleModels({
region: "us-east-1",
@@ -326,11 +332,11 @@ describe("bedrock mantle discovery", () => {
});
it("returns empty array on permission error", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: false,
status: 403,
statusText: "Forbidden",
});
const response = modelDiscoveryResponse(
{ error: "forbidden" },
{ status: 403, statusText: "Forbidden" },
);
const mockFetch = vi.fn().mockResolvedValue(response);
const models = await discoverMantleModels({
region: "us-east-1",
@@ -339,6 +345,7 @@ describe("bedrock mantle discovery", () => {
});
expect(models).toStrictEqual([]);
expect(response.bodyUsed).toBe(true);
});
it("returns empty array on network error", async () => {
@@ -354,16 +361,15 @@ describe("bedrock mantle discovery", () => {
});
it("filters out models with empty IDs", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
const mockFetch = vi.fn().mockResolvedValue(
modelDiscoveryResponse({
data: [
{ id: "anthropic.claude-sonnet-4-6", object: "model" },
{ id: "", object: "model" },
{ id: " ", object: "model" },
],
}),
});
);
const models = await discoverMantleModels({
region: "us-east-1",
@@ -375,18 +381,57 @@ describe("bedrock mantle discovery", () => {
expect(models[0]?.id).toBe("anthropic.claude-sonnet-4-6");
});
it("passes a timeout signal to Mantle model discovery fetches", async () => {
const controller = new AbortController();
const timeoutSpy = vi.spyOn(AbortSignal, "timeout").mockReturnValue(controller.signal);
const mockFetch = vi.fn().mockResolvedValue(
modelDiscoveryResponse({
data: [{ id: "anthropic.claude-sonnet-4-6", object: "model" }],
}),
);
await discoverMantleModels({
region: "us-east-1",
bearerToken: "test-token",
fetchFn: mockFetch as unknown as typeof fetch,
});
expect(timeoutSpy).toHaveBeenCalledWith(30_000);
expect(objectArgAt(mockFetch, 0, 1).signal).toBe(controller.signal);
});
it("bounds successful Mantle model discovery JSON responses", async () => {
const json = vi.fn(async () => {
throw new Error("response.json() should not be called");
});
const response = new Response("x".repeat(4 * 1024 * 1024 + 1), {
status: 200,
headers: { "Content-Type": "application/json" },
});
Object.defineProperty(response, "json", { value: json });
const mockFetch = vi.fn().mockResolvedValue(response);
const models = await discoverMantleModels({
region: "us-east-1",
bearerToken: "test-token",
fetchFn: mockFetch as unknown as typeof fetch,
});
expect(models).toStrictEqual([]);
expect(json).not.toHaveBeenCalled();
});
// ---------------------------------------------------------------------------
// 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 () => ({
const mockFetch = vi.fn().mockResolvedValue(
modelDiscoveryResponse({
data: [{ id: "anthropic.claude-sonnet-4-6", object: "model" }],
}),
});
);
// First call — hits the network
const first = await discoverMantleModels({
@@ -425,12 +470,11 @@ describe("bedrock mantle discovery", () => {
let now = 1000000;
const mockFetch = vi
.fn()
.mockResolvedValueOnce({
ok: true,
json: async () => ({
.mockResolvedValueOnce(
modelDiscoveryResponse({
data: [{ id: "anthropic.claude-sonnet-4-6", object: "model" }],
}),
})
)
.mockRejectedValueOnce(new Error("ECONNREFUSED"));
// First call — succeeds
@@ -458,12 +502,11 @@ describe("bedrock mantle discovery", () => {
// ---------------------------------------------------------------------------
it("resolves implicit provider when bearer token is set", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
const mockFetch = vi.fn().mockResolvedValue(
modelDiscoveryResponse({
data: [{ id: "anthropic.claude-sonnet-4-6", object: "model" }],
}),
});
);
const provider = await resolveImplicitMantleProvider({
env: {
@@ -510,12 +553,11 @@ 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 () => ({
const mockFetch = vi.fn().mockResolvedValue(
modelDiscoveryResponse({
data: [{ id: "openai.gpt-oss-120b", object: "model" }],
}),
});
);
const provider = await resolveImplicitMantleProvider({
env: {
@@ -604,10 +646,11 @@ describe("bedrock mantle discovery", () => {
});
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 mockFetch = vi
.fn()
.mockResolvedValue(
modelDiscoveryResponse({ data: [{ id: "openai.gpt-oss-120b", object: "model" }] }),
);
const provider = await resolveImplicitMantleProvider({
env: {
+25 -1
View File
@@ -12,6 +12,7 @@ import type {
ModelDefinitionConfig,
ModelProviderConfig,
} from "openclaw/plugin-sdk/provider-model-shared";
import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
const log = createSubsystemLogger("bedrock-mantle-discovery");
@@ -26,6 +27,8 @@ const DEFAULT_COST = {
const DEFAULT_CONTEXT_WINDOW = 32000;
const DEFAULT_MAX_TOKENS = 4096;
const DEFAULT_REFRESH_INTERVAL_SECONDS = 3600; // 1 hour
const MANTLE_DISCOVERY_TIMEOUT_MS = 30_000;
const MANTLE_DISCOVERY_RESPONSE_MAX_BYTES = 4 * 1024 * 1024;
/** Config auth marker meaning Mantle should mint runtime bearer tokens from IAM. */
export const MANTLE_IAM_TOKEN_MARKER = "__amazon_bedrock_mantle_iam__";
@@ -232,6 +235,25 @@ function inferReasoningSupport(modelId: string): boolean {
return REASONING_PATTERNS.some((p) => lower.includes(p));
}
async function readMantleModelDiscoveryJson(response: Response): Promise<OpenAIModelsResponse> {
const bytes = await readResponseWithLimit(response, MANTLE_DISCOVERY_RESPONSE_MAX_BYTES, {
chunkTimeoutMs: MANTLE_DISCOVERY_TIMEOUT_MS,
onOverflow: ({ size, maxBytes }) =>
new Error(
`Mantle model discovery response exceeded ${maxBytes} bytes (${size} bytes received)`,
),
onIdleTimeout: ({ chunkTimeoutMs }) =>
new Error(
`Mantle model discovery response stalled: no data received for ${chunkTimeoutMs}ms`,
),
});
const body = JSON.parse(new TextDecoder().decode(bytes)) as unknown;
if (!body || typeof body !== "object" || Array.isArray(body)) {
return {};
}
return body as OpenAIModelsResponse;
}
// ---------------------------------------------------------------------------
// Discovery cache
// ---------------------------------------------------------------------------
@@ -288,6 +310,7 @@ export async function discoverMantleModels(params: {
try {
const response = await fetchFn(endpoint, {
method: "GET",
signal: AbortSignal.timeout(MANTLE_DISCOVERY_TIMEOUT_MS),
headers: {
Authorization: `Bearer ${bearerToken}`,
Accept: "application/json",
@@ -295,6 +318,7 @@ export async function discoverMantleModels(params: {
});
if (!response.ok) {
await response.body?.cancel().catch(() => undefined);
log.debug?.("Mantle model discovery failed", {
status: response.status,
statusText: response.statusText,
@@ -302,7 +326,7 @@ export async function discoverMantleModels(params: {
return cached?.models ?? [];
}
const body = (await response.json()) as OpenAIModelsResponse;
const body = await readMantleModelDiscoveryJson(response);
const rawModels = body.data ?? [];
const models = rawModels