Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@

- `POST /__aimock/reset` — now a deprecated alias for `/__aimock/reset/fixtures`; it still performs a full reset but emits a `Deprecation` response header and a `deprecated` field in the body. Use the explicit `/reset/fixtures` or `/reset/journal` routes instead.

### Fixed

- **Video** — `POST /v1/videos` (`videos.create`) now parses `multipart/form-data` bodies. The OpenAI SDK (>=6.28.0) sends video-create requests as multipart instead of JSON (even for File-less bodies), which previously returned a `400 invalid_json`. The handler reuses the existing transcription multipart field parser and preserves the JSON path for older SDKs.

## [1.28.0] - 2026-06-02

### Added
Expand Down
2 changes: 1 addition & 1 deletion docs/video/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ <h2>Endpoints</h2>
<tr>
<td>POST</td>
<td>/v1/videos</td>
<td>JSON (create video job)</td>
<td>JSON or multipart form-data (create video job)</td>
</tr>
<tr>
<td>GET</td>
Expand Down
27 changes: 27 additions & 0 deletions src/__tests__/multimedia.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,33 @@ describe("video generation", () => {
expect(res.status).toBe(404);
await mock.stop();
});

test("video creation parses multipart/form-data body (OpenAI SDK 6.28.0+)", async () => {
const mock = new LLMock({ port: 0 });
mock.addFixture({
match: { userMessage: "a guitar", endpoint: "video" },
response: {
video: { id: "vid_mp", status: "completed", url: "https://example.com/video.mp4" },
},
});
await mock.start();

// The OpenAI SDK (>=6.28.0) sends videos.create as multipart/form-data.
// `fetch` sets the multipart Content-Type (with boundary) automatically.
const form = new FormData();
form.set("model", "sora-2");
form.set("prompt", "a guitar");
form.set("seconds", "8");
const create = await fetch(`${mock.url}/v1/videos`, {
method: "POST",
headers: { Authorization: "Bearer test" },
body: form,
});
const job = await create.json();
expect(job.id).toBe("vid_mp");
expect(job.status).toBe("completed");
await mock.stop();
});
});

describe("convenience methods", () => {
Expand Down
72 changes: 49 additions & 23 deletions src/video.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { writeErrorResponse } from "./sse-writer.js";
import type { Journal } from "./journal.js";
import { applyChaos } from "./chaos.js";
import { proxyAndRecord } from "./recorder.js";
import { extractBoundary, extractFormField } from "./transcription.js";

interface VideoRequest {
model?: string;
Expand Down Expand Up @@ -119,30 +120,55 @@ export async function handleVideoCreate(
const path = req.url ?? "/v1/videos";
const method = req.method ?? "POST";

const contentType = Array.isArray(req.headers["content-type"])
? req.headers["content-type"][0]
: req.headers["content-type"];
const isMultipart = (contentType ?? "").toLowerCase().includes("multipart/form-data");

let videoReq: VideoRequest;
try {
videoReq = JSON.parse(raw) as VideoRequest;
} catch (parseErr) {
const detail = parseErr instanceof Error ? parseErr.message : "unknown";
journal.add({
method,
path,
headers: flattenHeaders(req.headers),
body: null,
response: { status: 400, fixture: null },
});
writeErrorResponse(
res,
400,
JSON.stringify({
error: {
message: `Malformed JSON: ${detail}`,
type: "invalid_request_error",
code: "invalid_json",
},
}),
);
return;
if (isMultipart) {
// The OpenAI SDK (6.28.0+) sends POST /v1/videos as multipart/form-data;
// older SDKs sent JSON for a File-less body. Parse the form fields into the
// same shape the JSON path produces, reusing the transcription multipart
// helpers. Numeric fields (e.g. `seconds`) arrive as strings and are
// coerced to numbers to match the JSON body's types.
const boundary = extractBoundary(contentType);
const prompt = extractFormField(raw, "prompt", boundary);
const model = extractFormField(raw, "model", boundary);
const size = extractFormField(raw, "size", boundary);
const secondsRaw = extractFormField(raw, "seconds", boundary);
videoReq = { prompt: prompt ?? "" };
if (model !== undefined) videoReq.model = model;
if (size !== undefined) videoReq.size = size;
if (secondsRaw !== undefined) {
const secondsNum = Number(secondsRaw);
videoReq.seconds = Number.isNaN(secondsNum) ? secondsRaw : secondsNum;
}
} else {
try {
videoReq = JSON.parse(raw) as VideoRequest;
} catch (parseErr) {
const detail = parseErr instanceof Error ? parseErr.message : "unknown";
journal.add({
method,
path,
headers: flattenHeaders(req.headers),
body: null,
response: { status: 400, fixture: null },
});
writeErrorResponse(
res,
400,
JSON.stringify({
error: {
message: `Malformed JSON: ${detail}`,
type: "invalid_request_error",
code: "invalid_json",
},
}),
);
return;
}
}

if (!videoReq.prompt) {
Expand Down
Loading