Skip to content
44 changes: 44 additions & 0 deletions src/server/json-serialized-bytes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { Buffer } from "node:buffer";

type Frame =
| { items: readonly unknown[]; index: number }
| { record: Record<string, unknown>; keys: readonly string[]; index: number };

/**
* UTF-8 byte length of JSON.stringify(value) for a JSON.parse result, computed without
* recursion: a valid body nested deeper than the call stack must be measured, not crash.
* Each open container keeps one cursor frame, so arrays add no per-element queue; an object
* frame retains its key list, which stays bounded by the input size.
*/
export function serializedJsonByteLength(value: unknown): number {
let bytes = 0;
const stack: Frame[] = [];
const visit = (item: unknown): void => {
if (item === null || typeof item !== "object") {
bytes += Buffer.byteLength(JSON.stringify(item) ?? "null", "utf8");
} else if (Array.isArray(item)) {
bytes += 2 + Math.max(0, item.length - 1);
stack.push({ items: item, index: 0 });
} else {
const record = item as Record<string, unknown>;
const keys = Object.keys(record);
bytes += 2 + Math.max(0, keys.length - 1);
stack.push({ record, keys, index: 0 });
}
};
visit(value);
while (stack.length > 0) {
const frame = stack[stack.length - 1]!;
if ("items" in frame) {
if (frame.index < frame.items.length) visit(frame.items[frame.index++]);
else stack.pop();
} else if (frame.index < frame.keys.length) {
const key = frame.keys[frame.index++]!;
bytes += Buffer.byteLength(JSON.stringify(key), "utf8") + 1;
visit(frame.record[key]);
} else {
stack.pop();
}
}
return bytes;
}
6 changes: 4 additions & 2 deletions src/server/request-decompress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Buffer } from "node:buffer";
import { gunzipSync, inflateRawSync, inflateSync, zstdDecompressSync } from "node:zlib";
import type { TranslatorBudget } from "../lib/translator-budget";
import { updateInboundBodyReservation } from "./inbound-body-budget";
import { serializedJsonByteLength } from "./json-serialized-bytes";

/**
* Request-body decompression for the /v1/responses data plane.
Expand Down Expand Up @@ -333,8 +334,9 @@ export async function readBoundedJsonRequestBody(
return options.emptyBodyFallback;
}
const parsed = JSON.parse(text);
// Keep the serialized-size contract: normalization can expand numeric literals.
const parsedBytes = budget ? Buffer.byteLength(JSON.stringify(parsed), "utf8") : decoded.byteLength;
// Keep the serialized-size contract: normalization can expand numeric literals. The
// measurement walks iteratively, so a deeply nested valid body cannot overflow the stack.
const parsedBytes = serializedJsonByteLength(parsed);
budget?.observeAcceptedRequestCopy(parsedBytes);
updateInboundBodyReservation(req, Math.max(raw.byteLength, decoded.byteLength, parsedBytes));
return parsed;
Expand Down
9 changes: 6 additions & 3 deletions structure/transports/byte-accounting.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,11 +75,14 @@ only. It copies no request content and adds no bytes to the upstream wire; see t

`src/server/request-decompress.ts` observes the UTF-8 sizes of decoded text and reserialized JSON
without allocating encoded byte arrays solely to count them. Parsed-body accounting still uses
`JSON.stringify(parsed)`: numeric normalization can make it larger than the input text. These
the byte length of `JSON.stringify(parsed)`: numeric normalization can make it larger than the input
text. `src/server/json-serialized-bytes.ts` computes that length with an iterative walk, so a valid
body nested deeper than the call stack is measured instead of throwing during accounting. These
observations retain the existing ownership and release lifecycle and do not consume the translator's
hard byte cap. Per-body limits, parsing, compression, and reader error envelopes are unchanged.
`tests/usage/request-decompress.test.ts` covers exact accounting across codecs and Unicode/numeric
normalization, UTF-8 counting without encoded copies, and release after malformed or optional empty input.
normalization, UTF-8 counting without encoded copies, release after malformed or optional empty input,
and serialized-length parity with `JSON.stringify` including nesting it cannot walk.

The final native ChatGPT Responses HTTP send in `src/server/responses/fetch-helpers.ts` encodes JSON
strings of at least 1 MiB as a UTF-8 buffer for Bun upload compatibility. This is a transport copy,
Expand All @@ -105,7 +108,7 @@ discarding active leases or FIFO waiters; lowering capacity cannot erase outstan
checks, before any request body is read. Default and smaller allowances do not consume this budget.
Missing or small Content-Length and compressed wire bodies do not reduce the upload reservation.
After successful bounded JSON parsing, the reader reduces it to the largest observed wire/decoded
size, including normalized JSON bytes when the existing translator measurement is enabled.
size, including normalized JSON bytes whether or not the route uses a translator budget.
This reduction never exceeds the original allowance. The measured charge remains live;
parsing never frees the whole lease.
An already-aborted request or explicitly oversized declaration keeps the existing abort/413 path.
Expand Down
27 changes: 25 additions & 2 deletions tests/server/inbound-body-admission.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -591,7 +591,7 @@ describe("raised inbound body admission lifetime", () => {
}

for (const normalized of [false, true]) {
test(`retains measured JSON bytes with translator normalization ${normalized}`, async () => {
test(`retains normalized JSON bytes with translator budget ${normalized}`, async () => {
const text = "1e20";
const req = admissionRequest("/v1/responses", text);
const translator = normalized ? createTranslatorBudget() : undefined;
Expand All @@ -602,7 +602,7 @@ describe("raised inbound body admission lifetime", () => {
assert.equal(await readJsonRequestBody(req, translator, RAISED_BODY_LIMIT), 1e20);
return new Response("held");
}, req);
const expected = normalized ? Buffer.byteLength(JSON.stringify(1e20)) : Buffer.byteLength(text);
const expected = Buffer.byteLength(JSON.stringify(1e20));
assert.deepEqual(testBudget.snapshot(), { reservedBytes: expected, activeLeases: 1, queuedRequests: 0 });
await response.text();
} finally {
Expand All @@ -612,6 +612,29 @@ describe("raised inbound body admission lifetime", () => {
});
}

for (const normalized of [false, true]) {
test(`measures a deeply nested valid JSON body with translator budget ${normalized}`, async () => {
// Deeper than the call stack allows a recursive serializer to walk, and well under the limit.
const depth = 500_000;
const text = "[".repeat(depth) + "]".repeat(depth);
const req = admissionRequest("/v1/responses", text);
const translator = normalized ? createTranslatorBudget() : undefined;
let response: Response | undefined;
try {
response = await admittedBodyWork(async () => {
assert.equal(Array.isArray(await readJsonRequestBody(req, translator, RAISED_BODY_LIMIT)), true);
return new Response("held");
}, req);
assert.equal(response.status, 200);
assert.deepEqual(testBudget.snapshot(), { reservedBytes: text.length, activeLeases: 1, queuedRequests: 0 });
await response.text();
} finally {
await response?.body?.cancel().catch(() => undefined);
translator?.dispose();
}
});
}

for (const end of ["eof", "error", "cancel"] as const) {
test(`retains decoded compressed bytes until response ${end} settles exactly once`, async () => {
const text = JSON.stringify({ input: "x".repeat(10_000) });
Expand Down
31 changes: 31 additions & 0 deletions tests/usage/request-decompress.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
import { MANAGEMENT_JSON_BODY_MAX_BYTES } from "../../src/server/management/body";
import { handleManagementAPI } from "../../src/server/management-api";
import { decodeRequestErrorResponse } from "../../src/server/responses/core";
import { serializedJsonByteLength } from "../../src/server/json-serialized-bytes";
import type { OcxConfig } from "../../src/types";

const PAYLOAD = { model: "gpt-5.5", input: "hello", stream: true };
Expand Down Expand Up @@ -624,3 +625,33 @@ describe("readJsonRequestBody", () => {
await expect(readJsonRequestBody(req)).rejects.toBeInstanceOf(SyntaxError);
});
});

describe("serializedJsonByteLength", () => {
test("matches the UTF-8 length of JSON.stringify for parsed JSON", () => {
const sources = [
"null", "true", "false", "0", "-0", "1e20", "1e400", "-1e400", "0.1", "1.5e-7", "123456789012345678901234567890",
"\"\"", "\"plain\"", "\"\\ud800\"", "\"\\udc00x\\ud83d\\ude00\"", "\"\\u0000\\u001f\\u007f\"", "\"é漢字\\\"\\\\\\n\"",
"[]", "{}", "[[]]", "[{}]", "{\"a\":{}}", "[[],[[]],{}]", "[1,\"x\",null,true,[2,3],{\"k\":[]}]",
"{\"__proto__\":{\"x\":1},\"constructor\":[1]}", "{\"a\":1,\"a\":2}", "{\"2\":1,\"1\":2,\"b\":3,\"a\":4}",
"{\"\\ud800\":\"\\udfff\",\"é\":\"漢\"}", JSON.stringify({ input: [{ role: "user", content: "x".repeat(1000) }] }),
];
for (const source of sources) {
const parsed: unknown = JSON.parse(source);
expect([source, serializedJsonByteLength(parsed)]).toEqual([source, Buffer.byteLength(JSON.stringify(parsed), "utf8")]);
}
});

test("measures nesting deeper than a recursive serializer can walk", () => {
const depth = 500_000;
const parsed: unknown = JSON.parse("[".repeat(depth) + "]".repeat(depth));
expect(() => JSON.stringify(parsed)).toThrow(RangeError);
expect(serializedJsonByteLength(parsed)).toBe(2 * depth);
});

test("measures wide and mixed containers exactly", () => {
const wide = JSON.parse("[" + Array(1_000_000).fill("0").join(",") + "]");
expect(serializedJsonByteLength(wide)).toBe(Buffer.byteLength(JSON.stringify(wide), "utf8"));
const mixed = JSON.parse(JSON.stringify({ a: Array.from({ length: 50_000 }, (_, i) => ({ [`k${i}`]: [i, "é", null] })) }));
expect(serializedJsonByteLength(mixed)).toBe(Buffer.byteLength(JSON.stringify(mixed), "utf8"));
});
});
Loading