Files API
The OpenAI Files endpoints — POST /v1/files, GET /v1/files,
GET /v1/files/{id}, GET /v1/files/{id}/content and
DELETE /v1/files/{id} — are mocked in-memory so upload flows (fine-tune
datasets, batch inputs, Responses-API file inputs) run without touching the real API.
Multipart uploads are stored byte-for-byte, so binary files round-trip unchanged; the JSON
upload body carries UTF-8 text rather than arbitrary octets.
Endpoints
| Method | Path | Request Body | Response |
|---|---|---|---|
| POST | /v1/files |
multipart/form-data or JSON |
File object |
| GET | /v1/files |
— (optional ?purpose=, ?limit=,
?order=, ?after=)
|
{ object: "list", data: [...], first_id, last_id, has_more } |
| GET | /v1/files/{id} |
— | File object, or 404 |
| GET | /v1/files/{id}/content |
— | Raw bytes, or 404 |
| DELETE | /v1/files/{id} |
— | { id, object: "file", deleted: true }, or 404 |
| PATCH, HEAD, PUT (any other method) | /v1/files, /v1/files/{id} |
— |
404 { error: { message: "Not found", type: "not_found" } } — every
branch is method-guarded and nothing answers 405
|
Upload with Multipart
import fs from "node:fs";
import OpenAI from "openai";
import { LLMock } from "@copilotkit/aimock";
const mock = new LLMock({ port: 0 });
await mock.start();
const client = new OpenAI({ baseURL: `${mock.url}/v1`, apiKey: "test" });
const file = await client.files.create({
file: fs.createReadStream("./train.jsonl"),
purpose: "fine-tune",
});
// file.id === "file-…", file.bytes === the real byte length
The Content-Type must carry a boundary, and the body must carry
exactly one payload part, named either file or content. Sending
both parts is ambiguous — the filename from one could be paired with the bytes of the
other — so it is rejected with a 400 naming the two parts, and sending
neither is rejected as well. The same rule applies to purpose: two
purpose parts are a 400 rather than a silent last-one-wins.
Every part needs a name parameter on its
Content-Disposition header, and a part without one is a
400 naming that part by position.
Part bodies are framed as RFC 2046 delimiters — CRLF, --,
boundary — rather than by searching for the boundary token anywhere in the body, so a
payload that happens to contain its own boundary is stored whole instead of silently
truncated at the first match.
Upload with JSON
For tests that would rather not build a multipart body, the mock also accepts a plain JSON
body with filename, purpose, and content (aliased
as bytes). Send exactly one of content or bytes.
Supplying both is ambiguous and is rejected with a 400, as is a non-string
value or a body with neither.
This path carries text, not octets. The string is encoded as UTF-8, so a
content of "héllo" is stored as six bytes rather than five, and
a string holding an unpaired UTF-16 surrogate — which has no UTF-8 encoding at all — is
rejected with a 400 instead of being stored as U+FFFD. Binary
belongs on the multipart path above.
curl -X POST http://localhost:4010/v1/files \
-H "Content-Type: application/json" \
-d '{"filename":"train.jsonl","purpose":"fine-tune","content":"{\"prompt\":\"hi\"}\n"}'
Purposes
Uploads are validated against the OpenAI create purpose enum. The three
server-minted output purposes are rejected with a 400 on upload, matching the
FilePurpose type the OpenAI SDK admits on
files.create ([email protected], resources/files.d.ts); the live
API's runtime answer is not something the mock verifies. The mock mints one of them
itself: the batches endpoint stores a batch_output file named
<batch-id>_output.jsonl when a batch completes, and a
batch_output file named <batch-id>_error.jsonl when it
fails. Those files are ordinary entries in the store, so
?purpose=batch_output returns them and
GET /v1/files/{id}/content serves their JSONL. The other two response-only
purposes are listed for parity with the SDK's response enum; nothing in the mock mints
them. The ?purpose= list filter is not validated against either list —
the real API declares it as a free-form string, so an unrecognised value simply matches
nothing and returns an empty list with a 200. A ?purpose= with
no value at all is the one exception: it is a 400, because an empty string is
not a purpose any stored file can carry and handing back the whole store is the opposite
of the narrowing that was asked for.
| Accepted on upload | Response-only |
|---|---|
assistants, batch, fine-tune,
vision, user_data, evals
|
assistants_output, batch_output,
fine-tune-results
|
Listing & Paging
GET /v1/files is a cursor page. order defaults to
desc, so the newest file comes back first; asc is the only other
accepted value and anything else is a 400. limit must be an
unsigned decimal integer between 1 and 10000, and
defaults to 10000 — the Files endpoint is the odd one out among the list
surfaces, which mostly default to 20. Spellings that JavaScript's
Number() would otherwise accept — 0x10, 1e3,
+5, a whitespace-padded 5 — are rejected with a
400 rather than quietly becoming a page size. after takes the
last_id of the previous page; a cursor matching no file in the
current listing is a 400 rather than a silent restart at page one,
so a paging bug fails the test instead of looping forever. All four parameters obey the
same two rules on top of their own: one given twice is a 400 naming it and
the count, and one present with an empty value — ?limit=,
?order=, ?after=, ?purpose=, which is what building
a URL from a partly-undefined params object emits — is a 400 telling you to
omit it instead. A # in a value is part of that value: a request target has
no fragment, so ?purpose=batch#frag asks for the purpose
batch#frag and matches nothing. Every page carries first_id,
last_id and has_more.
Binary Content
Uploaded bytes are stored as a Buffer and never round-tripped through a UTF-8
string, so GET /v1/files/{id}/content returns exactly the octets that were
uploaded — a PNG, a PDF, or a zip comes back with an identical SHA-256 and byte count. The
response Content-Type is derived from the stored filename's extension — never
from the Content-Type declared on the multipart part, which is
client-supplied and which real SDKs set to application/octet-stream for every
binary upload. Unrecognised extensions — and .html, which is deliberately off
the allowlist — are served as application/octet-stream. Every content
response carries X-Content-Type-Options: nosniff and
Content-Disposition: attachment naming the stored file, so the mock never
renders stored content from its own origin — including a
application/pdf upload, which a browser's built-in viewer would otherwise run
as an active surface. A stored name containing any character outside printable ASCII
(0x20 to 0x7E) also gets an RFC 5987
filename*=UTF-8''… parameter alongside an ASCII fallback in which each UTF-16
code unit outside that range becomes a single _ (so é is one
underscore and an astral character is two). Neither header is listed in the mock's
Access-Control-Expose-Headers, which exposes only
X-Total-Count and X-Request-Id, so cross-origin browser code
cannot read Content-Disposition or X-Content-Type-Options; a
same-origin or Node client sees both. Because the part body is stored verbatim, a file
part that declares a Content-Transfer-Encoding other than
7bit/8bit/binary/identity (or a
non-identity Content-Encoding) is rejected with a
400 naming the header rather than stored still-encoded.
A payload part carrying no filename parameter is legal — as is a JSON
filename that is empty or only spaces (a tab counts as a control character,
so a tab-only name is a 400; the control-character check runs before the
blank check on both paths) — and the stored name is synthesized deterministically as
upload-<first 24 hex digits of the content's SHA-256>.bin — the same
bytes always produce the same name on either path, so a test can assert on it, and the
extension-derived Content-Type is then application/octet-stream. A JSON body
that omits filename entirely is a 400 rather than a synthesis,
because the key is declared and its value has to be a string. A part that sends only the
RFC 5987 filename*= form keeps the name it sent rather than getting a
synthesized one.
// continues the upload snippet above — same `fs`, `OpenAI`, `mock` and `client`
const uploaded = await fs.promises.readFile("pixel.png");
const created = await client.files.create({
file: await OpenAI.toFile(uploaded, "pixel.png"),
purpose: "user_data",
});
const res = await fetch(`${mock.url}/v1/files/${created.id}/content`);
const got = Buffer.from(await res.arrayBuffer());
got.equals(uploaded); // true
created.bytes === uploaded.length; // true
Limits & Reset
-
Per-file content is capped at 10 MB (10,485,760 bytes). An upload of exactly that
size still succeeds with a
200; one byte more is a400carrying the usualAccess-Control-*headers, and so is a body arbitrarily far past the cap, up to the 125,960,192-byte drain backstop in the next item — below that bound the answer is a status, not a dropped socket. -
A request body above 62,980,096 bytes gets its own
400(Request body exceeds the 62980096 byte upload limit for this route (the 10485760 byte content cap plus worst-case wire encoding overhead)). That figure is the content cap times the worst-case six wire bytes a single content byte can cost as a\uXXXXJSON escape, plus headroom, so no body that could still decode to a within-cap payload is ever refused for its size. Bytes past the bound are counted rather than retained, and a multipart body is scanned where it landed rather than copied first in order to be scanned, so peak memory tracks the bound and not the sender. Only a body that keeps arriving past 125,960,192 bytes (twice the buffering bound,FILES_BODY_DRAIN_MAX_BYTES) is cut off: the request is destroyed mid-body as a denial-of-service backstop, so the client gets no status line, no envelope and noAccess-Control-*headers, only a reset connection. This is the one socket drop on the files routes. -
A
filenameis capped at 1024 UTF-8 bytes on both upload paths. The name is echoed into theContent-DispositionofGET /v1/files/{id}/contenttwice — quoted ASCII plus the RFC 5987 form — so a longer one makes that response unfetchable, its headers overflowing before any client can read a status. The cap is aimock's own; the API's own schema states none. -
Storage is in-memory and per-process — nothing touches disk. The store is module-global
rather than per-instance, so two
LLMockobjects in one process share it and either one'sreset()clears both. A stored file holds an exact copy of its own bytes, never a view of the request it arrived in, so residency is the sum of the stored file sizes: ten 2-byte files uploaded in 5 MB request bodies retain 20 bytes, not 50 MB. Per-file size is capped and the filename is capped, but the file count is not, so residency still grows until a reset. -
POST /__aimock/reset(andLLMock.reset()) clears every stored file along with the rest of the mock state.
Chaos & CORS
Every files route runs through the chaos gate, so
chaos testing can inject failures at the upload surface too.
Faulted responses still carry the full Access-Control-* header set, so a
browser-based suite sees the injected status rather than an opaque CORS error.
Journal Integration
Files requests are recorded in the journal with service: "files". A
successful upload also carries a synthetic body describing what was uploaded
— purpose, filename, bytes,
content_type and the content's sha256, plus the three inert keys
every journal body carries: model: "", messages: [] and
_endpointType: "files". Those eight keys are the whole descriptor. The
uploaded octets themselves are never journalled in any encoding; sha256 is
how a test pins byte identity without a megabyte of binary in the journal. Every other
files entry — list, retrieve, content, delete, a chaos-faulted request, and an upload
rejected with a 400 (bad purpose, oversize body, malformed multipart) —
carries body: null, because there is no parsed payload to describe. Around
the body, each entry has the standard journal keys: id,
timestamp, method, path, headers,
service: "files" and response: { status, fixture: null }.
const entries = mock.getRequests();
const fileRequests = entries.filter((e) => e.service === "files");
// fileRequests[0] — the JSON upload from the snippet above, as journalled:
{
id: "req-K0-lLOhnCH1K8w9w",
timestamp: 1789578457013,
method: "POST",
path: "/v1/files",
headers: { host: "127.0.0.1:4010", "content-type": "application/json", /* … */ },
body: {
model: "",
messages: [],
_endpointType: "files",
purpose: "fine-tune",
filename: "train.jsonl",
bytes: 16,
content_type: "application/jsonl",
sha256: "855a2484322c2b66d92157e7f1594aec200407b1ad216a7caa38a37effc60641",
},
service: "files",
response: { status: 200, fixture: null },
}
// an upload rejected with a 400 (e.g. purpose: "nope") journals the same envelope with body: null
{ method: "POST", path: "/v1/files", body: null, service: "files", response: { status: 400, fixture: null } /* … */ }
See also the Services overview, the Control API for reset and journal inspection, and Chaos Testing.