mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
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:
co-authored by
Peter Steinberger
parent
f1a374a73a
commit
a77ea59ce7
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user