Skip to content

Commit ba8a649

Browse files
committed
fix(providers): harden malformed success responses
1 parent ea4e3cd commit ba8a649

7 files changed

Lines changed: 384 additions & 50 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ Docs: https://docs.openclaw.ai
1717
- Plugins: reject malformed `package.json` `openclaw.extensions` metadata during install, discovery, and post-update payload smoke instead of silently dropping invalid entries.
1818
- Media/files: sniff `input_file` bytes before trusting declared MIME headers, rejecting spoofed image or zip payloads before they become agent-visible text.
1919
- 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.
20+
- 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.
2021
- 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.
2122
- Plugin releases: require external package compatibility metadata in the npm plugin publish plan, matching the ClawHub package contract before packages ship.
2223
- 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.

extensions/byteplus/video-generation-provider.test.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,4 +144,80 @@ describe("byteplus video generation provider", () => {
144144
expect(body.resolution).toBe("480p");
145145
expect(body.camera_fixed).toBe(false);
146146
});
147+
148+
it("reports malformed create JSON with a provider-owned error", async () => {
149+
const release = vi.fn(async () => {});
150+
postJsonRequestMock.mockResolvedValue({
151+
response: {
152+
json: async () => {
153+
throw new SyntaxError("bad json");
154+
},
155+
},
156+
release,
157+
});
158+
159+
const provider = buildBytePlusVideoGenerationProvider();
160+
await expect(
161+
provider.generateVideo({
162+
provider: "byteplus",
163+
model: "seedance-1-0-lite-t2v-250428",
164+
prompt: "bad create response",
165+
cfg: {},
166+
}),
167+
).rejects.toThrow("BytePlus video generation failed: malformed JSON response");
168+
expect(release).toHaveBeenCalledOnce();
169+
});
170+
171+
it("rejects status responses missing a task status", async () => {
172+
postJsonRequestMock.mockResolvedValue({
173+
response: {
174+
json: async () => ({ id: "task_missing_status" }),
175+
},
176+
release: vi.fn(async () => {}),
177+
});
178+
fetchWithTimeoutMock.mockResolvedValueOnce({
179+
json: async () => ({
180+
id: "task_missing_status",
181+
content: {
182+
video_url: "https://example.com/byteplus.mp4",
183+
},
184+
}),
185+
});
186+
187+
const provider = buildBytePlusVideoGenerationProvider();
188+
await expect(
189+
provider.generateVideo({
190+
provider: "byteplus",
191+
model: "seedance-1-0-lite-t2v-250428",
192+
prompt: "missing status",
193+
cfg: {},
194+
}),
195+
).rejects.toThrow("BytePlus video status response missing task status");
196+
});
197+
198+
it("rejects malformed completed content", async () => {
199+
postJsonRequestMock.mockResolvedValue({
200+
response: {
201+
json: async () => ({ id: "task_malformed_content" }),
202+
},
203+
release: vi.fn(async () => {}),
204+
});
205+
fetchWithTimeoutMock.mockResolvedValueOnce({
206+
json: async () => ({
207+
id: "task_malformed_content",
208+
status: "succeeded",
209+
content: ["https://example.com/byteplus.mp4"],
210+
}),
211+
});
212+
213+
const provider = buildBytePlusVideoGenerationProvider();
214+
await expect(
215+
provider.generateVideo({
216+
provider: "byteplus",
217+
model: "seedance-1-0-lite-t2v-250428",
218+
prompt: "malformed content",
219+
cfg: {},
220+
}),
221+
).rejects.toThrow("BytePlus video generation completed with malformed content");
222+
});
147223
});

extensions/byteplus/video-generation-provider.ts

Lines changed: 79 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -27,27 +27,74 @@ const POLL_INTERVAL_MS = 5_000;
2727
const MAX_POLL_ATTEMPTS = 120;
2828

2929
type BytePlusTaskCreateResponse = {
30-
id?: string;
30+
id?: unknown;
3131
};
3232

3333
type BytePlusTaskResponse = {
34-
id?: string;
35-
model?: string;
36-
status?: "running" | "failed" | "queued" | "succeeded" | "cancelled";
37-
error?: {
38-
code?: string;
39-
message?: string;
40-
};
41-
content?: {
42-
video_url?: string;
43-
last_frame_url?: string;
44-
file_url?: string;
45-
};
46-
duration?: number;
47-
ratio?: string;
48-
resolution?: string;
34+
id?: unknown;
35+
model?: unknown;
36+
status?: unknown;
37+
error?: unknown;
38+
content?: unknown;
39+
duration?: unknown;
40+
ratio?: unknown;
41+
resolution?: unknown;
4942
};
5043

44+
type BytePlusTaskStatus = "running" | "failed" | "queued" | "succeeded" | "cancelled";
45+
46+
function isRecord(value: unknown): value is Record<string, unknown> {
47+
return typeof value === "object" && value !== null && !Array.isArray(value);
48+
}
49+
50+
async function readBytePlusJsonResponse<T>(
51+
response: Pick<Response, "json">,
52+
label: string,
53+
): Promise<T> {
54+
let payload: unknown;
55+
try {
56+
payload = await response.json();
57+
} catch (cause) {
58+
throw new Error(`${label}: malformed JSON response`, { cause });
59+
}
60+
if (!isRecord(payload)) {
61+
throw new Error(`${label}: malformed JSON response`);
62+
}
63+
return payload as T;
64+
}
65+
66+
function readBytePlusTaskStatus(payload: BytePlusTaskResponse): BytePlusTaskStatus {
67+
const status = normalizeOptionalString(payload.status);
68+
switch (status) {
69+
case "running":
70+
case "failed":
71+
case "queued":
72+
case "succeeded":
73+
case "cancelled":
74+
return status;
75+
case undefined:
76+
throw new Error("BytePlus video status response missing task status");
77+
default:
78+
throw new Error(`BytePlus video status response returned unknown task status: ${status}`);
79+
}
80+
}
81+
82+
function readBytePlusErrorMessage(error: unknown): string | undefined {
83+
return isRecord(error) ? normalizeOptionalString(error.message) : undefined;
84+
}
85+
86+
function readBytePlusVideoUrl(payload: BytePlusTaskResponse): string {
87+
const content = payload.content;
88+
if (content !== undefined && !isRecord(content)) {
89+
throw new Error("BytePlus video generation completed with malformed content");
90+
}
91+
const videoUrl = normalizeOptionalString(content?.video_url);
92+
if (!videoUrl) {
93+
throw new Error("BytePlus video generation completed without a video URL");
94+
}
95+
return videoUrl;
96+
}
97+
5198
function resolveBytePlusVideoBaseUrl(req: VideoGenerationRequest): string {
5299
return (
53100
normalizeOptionalString(req.cfg?.models?.providers?.byteplus?.baseUrl) ?? BYTEPLUS_BASE_URL
@@ -100,14 +147,17 @@ async function pollBytePlusTask(params: {
100147
provider: "byteplus",
101148
requestFailedMessage: "BytePlus video status request failed",
102149
});
103-
const payload = (await response.json()) as BytePlusTaskResponse;
104-
switch (normalizeOptionalString(payload.status)) {
150+
const payload = await readBytePlusJsonResponse<BytePlusTaskResponse>(
151+
response,
152+
"BytePlus video status request failed",
153+
);
154+
switch (readBytePlusTaskStatus(payload)) {
105155
case "succeeded":
106156
return payload;
107157
case "failed":
108158
case "cancelled":
109159
throw new Error(
110-
normalizeOptionalString(payload.error?.message) || "BytePlus video generation failed",
160+
readBytePlusErrorMessage(payload.error) || "BytePlus video generation failed",
111161
);
112162
case "queued":
113163
case "running":
@@ -292,7 +342,10 @@ export function buildBytePlusVideoGenerationProvider(): VideoGenerationProvider
292342
});
293343
try {
294344
await assertOkOrThrowHttpError(response, "BytePlus video generation failed");
295-
const submitted = (await response.json()) as BytePlusTaskCreateResponse;
345+
const submitted = await readBytePlusJsonResponse<BytePlusTaskCreateResponse>(
346+
response,
347+
"BytePlus video generation failed",
348+
);
296349
const taskId = normalizeOptionalString(submitted.id);
297350
if (!taskId) {
298351
throw new Error("BytePlus video generation response missing task id");
@@ -307,10 +360,7 @@ export function buildBytePlusVideoGenerationProvider(): VideoGenerationProvider
307360
baseUrl,
308361
fetchFn,
309362
});
310-
const videoUrl = normalizeOptionalString(completed.content?.video_url);
311-
if (!videoUrl) {
312-
throw new Error("BytePlus video generation completed without a video URL");
313-
}
363+
const videoUrl = readBytePlusVideoUrl(completed);
314364
const video = await downloadBytePlusVideo({
315365
url: videoUrl,
316366
timeoutMs: createProviderOperationTimeoutResolver({
@@ -321,14 +371,14 @@ export function buildBytePlusVideoGenerationProvider(): VideoGenerationProvider
321371
});
322372
return {
323373
videos: [video],
324-
model: completed.model ?? resolvedModel,
374+
model: normalizeOptionalString(completed.model) ?? resolvedModel,
325375
metadata: {
326376
taskId,
327-
status: completed.status,
377+
status: normalizeOptionalString(completed.status),
328378
videoUrl,
329-
ratio: completed.ratio,
330-
resolution: completed.resolution,
331-
duration: completed.duration,
379+
ratio: normalizeOptionalString(completed.ratio),
380+
resolution: normalizeOptionalString(completed.resolution),
381+
duration: typeof completed.duration === "number" ? completed.duration : undefined,
332382
},
333383
};
334384
} finally {

extensions/ollama/src/embedding-provider.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,56 @@ describe("ollama embedding provider", () => {
251251
expect(inputs).toEqual([["a", "bb", "ccc"]]);
252252
});
253253

254+
it("reports malformed embed JSON with a provider-owned error", async () => {
255+
vi.stubGlobal(
256+
"fetch",
257+
vi.fn(
258+
async () =>
259+
new Response("{not json", {
260+
status: 200,
261+
headers: { "content-type": "application/json" },
262+
}),
263+
),
264+
);
265+
266+
const { provider } = await createOllamaEmbeddingProvider({
267+
config: {} as OpenClawConfig,
268+
provider: "ollama",
269+
model: "nomic-embed-text",
270+
fallback: "none",
271+
remote: { baseUrl: "http://127.0.0.1:11434" },
272+
});
273+
274+
await expect(provider.embedQuery("hello")).rejects.toThrow(
275+
"Ollama embed response returned malformed JSON",
276+
);
277+
});
278+
279+
it("rejects non-number embedding values instead of zeroing them", async () => {
280+
vi.stubGlobal(
281+
"fetch",
282+
vi.fn(
283+
async () =>
284+
new Response(JSON.stringify({ embeddings: [["0.1", 0.2]] }), {
285+
status: 200,
286+
headers: { "content-type": "application/json" },
287+
}),
288+
),
289+
);
290+
291+
const { provider } = await createOllamaEmbeddingProvider({
292+
config: {} as OpenClawConfig,
293+
provider: "ollama",
294+
model: "nomic-embed-text",
295+
fallback: "none",
296+
remote: { baseUrl: "http://127.0.0.1:11434" },
297+
});
298+
299+
await expect(provider.embedQuery("hello")).rejects.toThrow(
300+
"Ollama embed response contains a non-number embedding value",
301+
);
302+
});
303+
254304
it("uses a retrieval query prefix for qwen3 embedding queries", async () => {
255305
const fetchMock = mockEmbeddingFetch([1, 0]);
256306

extensions/ollama/src/embedding-provider.ts

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -73,8 +73,13 @@ const QUERY_INSTRUCTION_TEMPLATES = [
7373
},
7474
] as const;
7575

76-
function sanitizeAndNormalizeEmbedding(vec: number[]): number[] {
77-
const sanitized = vec.map((value) => (Number.isFinite(value) ? value : 0));
76+
function sanitizeAndNormalizeEmbedding(vec: unknown[]): number[] {
77+
const sanitized = vec.map((value) => {
78+
if (typeof value !== "number") {
79+
throw new Error("Ollama embed response contains a non-number embedding value");
80+
}
81+
return Number.isFinite(value) ? value : 0;
82+
});
7883
const magnitude = Math.sqrt(sanitized.reduce((sum, value) => sum + value * value, 0));
7984
if (magnitude < 1e-10) {
8085
return sanitized;
@@ -101,6 +106,21 @@ async function withRemoteHttpResponse<T>(params: {
101106
}
102107
}
103108

109+
async function readOllamaEmbeddingJsonResponse(
110+
response: Pick<Response, "json">,
111+
): Promise<{ embeddings?: unknown }> {
112+
let payload: unknown;
113+
try {
114+
payload = await response.json();
115+
} catch (cause) {
116+
throw new Error("Ollama embed response returned malformed JSON", { cause });
117+
}
118+
if (typeof payload !== "object" || payload === null || Array.isArray(payload)) {
119+
throw new Error("Ollama embed response returned a non-object JSON payload");
120+
}
121+
return payload as { embeddings?: unknown };
122+
}
123+
104124
function normalizeEmbeddingModel(model: string, providerId?: string): string {
105125
const trimmed = model.trim();
106126
if (!trimmed) {
@@ -315,7 +335,7 @@ export async function createOllamaEmbeddingProvider(
315335
if (!response.ok) {
316336
throw new Error(`Ollama embed HTTP ${response.status}: ${await response.text()}`);
317337
}
318-
return (await response.json()) as { embeddings?: unknown };
338+
return await readOllamaEmbeddingJsonResponse(response);
319339
},
320340
});
321341
if (!Array.isArray(json.embeddings)) {

0 commit comments

Comments
 (0)