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

Multipart upload typescript
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.

JSON upload bash
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.

Byte-exact round-trip typescript
// 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

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 }.

Journal entries typescript
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.