fix(providers): harden malformed success responses

This commit is contained in:
Vincent Koc
2026-05-16 03:28:04 +08:00
parent ea4e3cd4fa
commit ba8a6499f0
7 changed files with 384 additions and 50 deletions

View File

@@ -17,6 +17,7 @@ Docs: https://docs.openclaw.ai
- Plugins: reject malformed `package.json` `openclaw.extensions` metadata during install, discovery, and post-update payload smoke instead of silently dropping invalid entries.
- Media/files: sniff `input_file` bytes before trusting declared MIME headers, rejecting spoofed image or zip payloads before they become agent-visible text.
- Config persistence: ignore malformed array/scalar auth profile, cron job state, and session store entries instead of hydrating them into numeric profile ids, crashed cron rows, or invalid session records.
- Providers: reject malformed successful Runway, BytePlus, and Ollama embedding responses with provider-owned errors instead of raw parser/type failures, silent bad vectors, or long bogus polling.
- Hooks: raise bounded gateway lifecycle hook wait budgets to 5 seconds for shutdown and 10 seconds for pre-restart, giving short restart notification handlers time to finish before shutdown continues. (#82273) Thanks @bryanbaer.
- Plugin releases: require external package compatibility metadata in the npm plugin publish plan, matching the ClawHub package contract before packages ship.
- Agents/OpenAI-compatible: honor per-model `max_completion_tokens`/`max_tokens` params in embedded OpenAI-completions runs so high-token Kimi-style routes keep their configured completion cap. Fixes #82230. Thanks @albert-zen.

View File

@@ -144,4 +144,80 @@ describe("byteplus video generation provider", () => {
expect(body.resolution).toBe("480p");
expect(body.camera_fixed).toBe(false);
});
it("reports malformed create JSON with a provider-owned error", async () => {
const release = vi.fn(async () => {});
postJsonRequestMock.mockResolvedValue({
response: {
json: async () => {
throw new SyntaxError("bad json");
},
},
release,
});
const provider = buildBytePlusVideoGenerationProvider();
await expect(
provider.generateVideo({
provider: "byteplus",
model: "seedance-1-0-lite-t2v-250428",
prompt: "bad create response",
cfg: {},
}),
).rejects.toThrow("BytePlus video generation failed: malformed JSON response");
expect(release).toHaveBeenCalledOnce();
});
it("rejects status responses missing a task status", async () => {
postJsonRequestMock.mockResolvedValue({
response: {
json: async () => ({ id: "task_missing_status" }),
},
release: vi.fn(async () => {}),
});
fetchWithTimeoutMock.mockResolvedValueOnce({
json: async () => ({
id: "task_missing_status",
content: {
video_url: "https://example.com/byteplus.mp4",
},
}),
});
const provider = buildBytePlusVideoGenerationProvider();
await expect(
provider.generateVideo({
provider: "byteplus",
model: "seedance-1-0-lite-t2v-250428",
prompt: "missing status",
cfg: {},
}),
).rejects.toThrow("BytePlus video status response missing task status");
});
it("rejects malformed completed content", async () => {
postJsonRequestMock.mockResolvedValue({
response: {
json: async () => ({ id: "task_malformed_content" }),
},
release: vi.fn(async () => {}),
});
fetchWithTimeoutMock.mockResolvedValueOnce({
json: async () => ({
id: "task_malformed_content",
status: "succeeded",
content: ["https://example.com/byteplus.mp4"],
}),
});
const provider = buildBytePlusVideoGenerationProvider();
await expect(
provider.generateVideo({
provider: "byteplus",
model: "seedance-1-0-lite-t2v-250428",
prompt: "malformed content",
cfg: {},
}),
).rejects.toThrow("BytePlus video generation completed with malformed content");
});
});

View File

@@ -27,27 +27,74 @@ const POLL_INTERVAL_MS = 5_000;
const MAX_POLL_ATTEMPTS = 120;
type BytePlusTaskCreateResponse = {
id?: string;
id?: unknown;
};
type BytePlusTaskResponse = {
id?: string;
model?: string;
status?: "running" | "failed" | "queued" | "succeeded" | "cancelled";
error?: {
code?: string;
message?: string;
};
content?: {
video_url?: string;
last_frame_url?: string;
file_url?: string;
};
duration?: number;
ratio?: string;
resolution?: string;
id?: unknown;
model?: unknown;
status?: unknown;
error?: unknown;
content?: unknown;
duration?: unknown;
ratio?: unknown;
resolution?: unknown;
};
type BytePlusTaskStatus = "running" | "failed" | "queued" | "succeeded" | "cancelled";
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
async function readBytePlusJsonResponse<T>(
response: Pick<Response, "json">,
label: string,
): Promise<T> {
let payload: unknown;
try {
payload = await response.json();
} catch (cause) {
throw new Error(`${label}: malformed JSON response`, { cause });
}
if (!isRecord(payload)) {
throw new Error(`${label}: malformed JSON response`);
}
return payload as T;
}
function readBytePlusTaskStatus(payload: BytePlusTaskResponse): BytePlusTaskStatus {
const status = normalizeOptionalString(payload.status);
switch (status) {
case "running":
case "failed":
case "queued":
case "succeeded":
case "cancelled":
return status;
case undefined:
throw new Error("BytePlus video status response missing task status");
default:
throw new Error(`BytePlus video status response returned unknown task status: ${status}`);
}
}
function readBytePlusErrorMessage(error: unknown): string | undefined {
return isRecord(error) ? normalizeOptionalString(error.message) : undefined;
}
function readBytePlusVideoUrl(payload: BytePlusTaskResponse): string {
const content = payload.content;
if (content !== undefined && !isRecord(content)) {
throw new Error("BytePlus video generation completed with malformed content");
}
const videoUrl = normalizeOptionalString(content?.video_url);
if (!videoUrl) {
throw new Error("BytePlus video generation completed without a video URL");
}
return videoUrl;
}
function resolveBytePlusVideoBaseUrl(req: VideoGenerationRequest): string {
return (
normalizeOptionalString(req.cfg?.models?.providers?.byteplus?.baseUrl) ?? BYTEPLUS_BASE_URL
@@ -100,14 +147,17 @@ async function pollBytePlusTask(params: {
provider: "byteplus",
requestFailedMessage: "BytePlus video status request failed",
});
const payload = (await response.json()) as BytePlusTaskResponse;
switch (normalizeOptionalString(payload.status)) {
const payload = await readBytePlusJsonResponse<BytePlusTaskResponse>(
response,
"BytePlus video status request failed",
);
switch (readBytePlusTaskStatus(payload)) {
case "succeeded":
return payload;
case "failed":
case "cancelled":
throw new Error(
normalizeOptionalString(payload.error?.message) || "BytePlus video generation failed",
readBytePlusErrorMessage(payload.error) || "BytePlus video generation failed",
);
case "queued":
case "running":
@@ -292,7 +342,10 @@ export function buildBytePlusVideoGenerationProvider(): VideoGenerationProvider
});
try {
await assertOkOrThrowHttpError(response, "BytePlus video generation failed");
const submitted = (await response.json()) as BytePlusTaskCreateResponse;
const submitted = await readBytePlusJsonResponse<BytePlusTaskCreateResponse>(
response,
"BytePlus video generation failed",
);
const taskId = normalizeOptionalString(submitted.id);
if (!taskId) {
throw new Error("BytePlus video generation response missing task id");
@@ -307,10 +360,7 @@ export function buildBytePlusVideoGenerationProvider(): VideoGenerationProvider
baseUrl,
fetchFn,
});
const videoUrl = normalizeOptionalString(completed.content?.video_url);
if (!videoUrl) {
throw new Error("BytePlus video generation completed without a video URL");
}
const videoUrl = readBytePlusVideoUrl(completed);
const video = await downloadBytePlusVideo({
url: videoUrl,
timeoutMs: createProviderOperationTimeoutResolver({
@@ -321,14 +371,14 @@ export function buildBytePlusVideoGenerationProvider(): VideoGenerationProvider
});
return {
videos: [video],
model: completed.model ?? resolvedModel,
model: normalizeOptionalString(completed.model) ?? resolvedModel,
metadata: {
taskId,
status: completed.status,
status: normalizeOptionalString(completed.status),
videoUrl,
ratio: completed.ratio,
resolution: completed.resolution,
duration: completed.duration,
ratio: normalizeOptionalString(completed.ratio),
resolution: normalizeOptionalString(completed.resolution),
duration: typeof completed.duration === "number" ? completed.duration : undefined,
},
};
} finally {

View File

@@ -251,6 +251,56 @@ describe("ollama embedding provider", () => {
expect(inputs).toEqual([["a", "bb", "ccc"]]);
});
it("reports malformed embed JSON with a provider-owned error", async () => {
vi.stubGlobal(
"fetch",
vi.fn(
async () =>
new Response("{not json", {
status: 200,
headers: { "content-type": "application/json" },
}),
),
);
const { provider } = await createOllamaEmbeddingProvider({
config: {} as OpenClawConfig,
provider: "ollama",
model: "nomic-embed-text",
fallback: "none",
remote: { baseUrl: "http://127.0.0.1:11434" },
});
await expect(provider.embedQuery("hello")).rejects.toThrow(
"Ollama embed response returned malformed JSON",
);
});
it("rejects non-number embedding values instead of zeroing them", async () => {
vi.stubGlobal(
"fetch",
vi.fn(
async () =>
new Response(JSON.stringify({ embeddings: [["0.1", 0.2]] }), {
status: 200,
headers: { "content-type": "application/json" },
}),
),
);
const { provider } = await createOllamaEmbeddingProvider({
config: {} as OpenClawConfig,
provider: "ollama",
model: "nomic-embed-text",
fallback: "none",
remote: { baseUrl: "http://127.0.0.1:11434" },
});
await expect(provider.embedQuery("hello")).rejects.toThrow(
"Ollama embed response contains a non-number embedding value",
);
});
it("uses a retrieval query prefix for qwen3 embedding queries", async () => {
const fetchMock = mockEmbeddingFetch([1, 0]);

View File

@@ -73,8 +73,13 @@ const QUERY_INSTRUCTION_TEMPLATES = [
},
] as const;
function sanitizeAndNormalizeEmbedding(vec: number[]): number[] {
const sanitized = vec.map((value) => (Number.isFinite(value) ? value : 0));
function sanitizeAndNormalizeEmbedding(vec: unknown[]): number[] {
const sanitized = vec.map((value) => {
if (typeof value !== "number") {
throw new Error("Ollama embed response contains a non-number embedding value");
}
return Number.isFinite(value) ? value : 0;
});
const magnitude = Math.sqrt(sanitized.reduce((sum, value) => sum + value * value, 0));
if (magnitude < 1e-10) {
return sanitized;
@@ -101,6 +106,21 @@ async function withRemoteHttpResponse<T>(params: {
}
}
async function readOllamaEmbeddingJsonResponse(
response: Pick<Response, "json">,
): Promise<{ embeddings?: unknown }> {
let payload: unknown;
try {
payload = await response.json();
} catch (cause) {
throw new Error("Ollama embed response returned malformed JSON", { cause });
}
if (typeof payload !== "object" || payload === null || Array.isArray(payload)) {
throw new Error("Ollama embed response returned a non-object JSON payload");
}
return payload as { embeddings?: unknown };
}
function normalizeEmbeddingModel(model: string, providerId?: string): string {
const trimmed = model.trim();
if (!trimmed) {
@@ -315,7 +335,7 @@ export async function createOllamaEmbeddingProvider(
if (!response.ok) {
throw new Error(`Ollama embed HTTP ${response.status}: ${await response.text()}`);
}
return (await response.json()) as { embeddings?: unknown };
return await readOllamaEmbeddingJsonResponse(response);
},
});
if (!Array.isArray(json.embeddings)) {

View File

@@ -169,4 +169,80 @@ describe("runway video generation provider", () => {
).rejects.toThrow("Runway video-to-video currently requires model gen4_aleph.");
expect(postJsonRequestMock).not.toHaveBeenCalled();
});
it("reports malformed create JSON with a provider-owned error", async () => {
const release = vi.fn(async () => {});
postJsonRequestMock.mockResolvedValue({
response: {
json: async () => {
throw new SyntaxError("bad json");
},
},
release,
});
const provider = buildRunwayVideoGenerationProvider();
await expect(
provider.generateVideo({
provider: "runway",
model: "gen4.5",
prompt: "bad create response",
cfg: {},
}),
).rejects.toThrow("Runway video generation failed: malformed JSON response");
expect(release).toHaveBeenCalledOnce();
});
it("rejects status responses missing a task status", async () => {
postJsonRequestMock.mockResolvedValue({
response: {
json: async () => ({ id: "task-missing-status" }),
},
release: vi.fn(async () => {}),
});
fetchWithTimeoutMock.mockResolvedValueOnce({
json: async () => ({
id: "task-missing-status",
output: ["https://example.com/out.mp4"],
}),
headers: new Headers(),
});
const provider = buildRunwayVideoGenerationProvider();
await expect(
provider.generateVideo({
provider: "runway",
model: "gen4.5",
prompt: "missing status",
cfg: {},
}),
).rejects.toThrow("Runway video status response missing task status");
});
it("rejects malformed completed output URLs", async () => {
postJsonRequestMock.mockResolvedValue({
response: {
json: async () => ({ id: "task-malformed-output" }),
},
release: vi.fn(async () => {}),
});
fetchWithTimeoutMock.mockResolvedValueOnce({
json: async () => ({
id: "task-malformed-output",
status: "SUCCEEDED",
output: "https://example.com/out.mp4",
}),
headers: new Headers(),
});
const provider = buildRunwayVideoGenerationProvider();
await expect(
provider.generateVideo({
provider: "runway",
model: "gen4.5",
prompt: "malformed output",
cfg: {},
}),
).rejects.toThrow("Runway video generation completed with malformed output URLs");
});
});

View File

@@ -36,14 +36,14 @@ const MAX_DURATION_SECONDS = 10;
type RunwayTaskStatus = "PENDING" | "RUNNING" | "THROTTLED" | "SUCCEEDED" | "FAILED" | "CANCELLED";
type RunwayTaskCreateResponse = {
id?: string;
id?: unknown;
};
type RunwayTaskDetailResponse = {
id?: string;
status?: RunwayTaskStatus;
output?: string[];
failure?: string | { message?: string } | null;
id?: unknown;
status?: unknown;
output?: unknown;
failure?: unknown;
};
type RunwaySourceAsset = Pick<VideoGenerationSourceAsset, "buffer" | "mimeType" | "url">;
@@ -61,6 +61,66 @@ const VIDEO_MODELS = new Set(["gen4_aleph"]);
const RUNWAY_TEXT_ASPECT_RATIOS = ["16:9", "9:16"] as const;
const RUNWAY_EDIT_ASPECT_RATIOS = ["1:1", "16:9", "9:16", "3:4", "4:3", "21:9"] as const;
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
async function readRunwayJsonResponse<T>(
response: Pick<Response, "json">,
label: string,
): Promise<T> {
let payload: unknown;
try {
payload = await response.json();
} catch (cause) {
throw new Error(`${label}: malformed JSON response`, { cause });
}
if (!isRecord(payload)) {
throw new Error(`${label}: malformed JSON response`);
}
return payload as T;
}
function readRunwayTaskStatus(payload: RunwayTaskDetailResponse): RunwayTaskStatus {
const status = normalizeOptionalString(payload.status);
switch (status) {
case "PENDING":
case "RUNNING":
case "THROTTLED":
case "SUCCEEDED":
case "FAILED":
case "CANCELLED":
return status;
case undefined:
throw new Error("Runway video status response missing task status");
default:
throw new Error(`Runway video status response returned unknown task status: ${status}`);
}
}
function readRunwayFailureMessage(failure: unknown): string | undefined {
if (typeof failure === "string") {
return normalizeOptionalString(failure);
}
if (isRecord(failure)) {
return normalizeOptionalString(failure.message);
}
return undefined;
}
function readRunwayOutputUrls(payload: RunwayTaskDetailResponse): string[] {
if (!Array.isArray(payload.output)) {
throw new Error("Runway video generation completed with malformed output URLs");
}
const outputUrls = payload.output
.map((value) => normalizeOptionalString(value))
.filter((value): value is string => Boolean(value));
if (!outputUrls.length) {
throw new Error("Runway video generation completed without output URLs");
}
return outputUrls;
}
function resolveRunwayBaseUrl(req: VideoGenerationRequest): string {
return (
normalizeOptionalString(req.cfg?.models?.providers?.runway?.baseUrl) ?? DEFAULT_RUNWAY_BASE_URL
@@ -226,16 +286,19 @@ async function pollRunwayTask(params: {
provider: "runway",
requestFailedMessage: "Runway video status request failed",
});
const payload = (await response.json()) as RunwayTaskDetailResponse;
switch (payload.status) {
const payload = await readRunwayJsonResponse<RunwayTaskDetailResponse>(
response,
"Runway video status request failed",
);
const status = readRunwayTaskStatus(payload);
switch (status) {
case "SUCCEEDED":
return payload;
case "FAILED":
case "CANCELLED":
throw new Error(
normalizeOptionalString(
typeof payload.failure === "string" ? payload.failure : payload.failure?.message,
) || `Runway video generation ${normalizeLowercaseStringOrEmpty(payload.status)}`,
readRunwayFailureMessage(payload.failure) ||
`Runway video generation ${normalizeLowercaseStringOrEmpty(status)}`,
);
case "PENDING":
case "RUNNING":
@@ -354,7 +417,10 @@ export function buildRunwayVideoGenerationProvider(): VideoGenerationProvider {
});
try {
await assertOkOrThrowHttpError(response, "Runway video generation failed");
const submitted = (await response.json()) as RunwayTaskCreateResponse;
const submitted = await readRunwayJsonResponse<RunwayTaskCreateResponse>(
response,
"Runway video generation failed",
);
const taskId = normalizeOptionalString(submitted.id);
if (!taskId) {
throw new Error("Runway video generation response missing task id");
@@ -369,12 +435,7 @@ export function buildRunwayVideoGenerationProvider(): VideoGenerationProvider {
baseUrl,
fetchFn,
});
const outputUrls = completed.output
?.map((value) => normalizeOptionalString(value))
.filter((value): value is string => Boolean(value));
if (!outputUrls?.length) {
throw new Error("Runway video generation completed without output URLs");
}
const outputUrls = readRunwayOutputUrls(completed);
const videos = await downloadRunwayVideos({
urls: outputUrls,
timeoutMs: createProviderOperationTimeoutResolver({
@@ -388,7 +449,7 @@ export function buildRunwayVideoGenerationProvider(): VideoGenerationProvider {
model: normalizeOptionalString(req.model) ?? DEFAULT_RUNWAY_MODEL,
metadata: {
taskId,
status: completed.status,
status: normalizeOptionalString(completed.status),
endpoint,
outputUrls,
},