diff --git a/docs-site/src/content/docs/reference/proxy-formats.md b/docs-site/src/content/docs/reference/proxy-formats.md index dc51e487641..70a9119b4a8 100644 --- a/docs-site/src/content/docs/reference/proxy-formats.md +++ b/docs-site/src/content/docs/reference/proxy-formats.md @@ -37,6 +37,11 @@ should select among several targets. This is the native CodexCommander data-plane shape. The request body must be a JSON object with a non-empty `model`. `input` may be a string or an array of Responses items. +Responses HTTP request bodies may be up to 256 MiB, both on the wire and after decoding +`Content-Encoding`. The HTTP listener and JSON parser use the same limit, so image-heavy histories +between 128 and 256 MiB can reach request processing. Larger bodies receive HTTP 413. Upstream +providers may impose additional request-size or context limits. + ### Accepted request fields | Area | Accepted shape | diff --git a/package.json b/package.json index 5fdc7c73493..08ae3ba4849 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codexcommander", - "version": "0.1.21", + "version": "0.1.22", "private": true, "description": "CodexCommander — universal provider proxy for OpenAI Codex & Claude Code — use any LLM with Codex CLI/App/SDK and Claude Code", "type": "module", diff --git a/src/server/index.ts b/src/server/index.ts index 6e953b13822..36720142fa2 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -159,6 +159,7 @@ export { } from "./auth-cors"; import { disableResponsesRequestTimeout, handleResponses, handleResponsesCompact } from "./responses"; export { disableResponsesRequestTimeout, linkAbortSignal } from "./responses"; +import { MAX_DECOMPRESSED_BODY_BYTES } from "./request-decompress"; import { handleClaudeCountTokens, handleClaudeMessages } from "./claude-messages"; import { handleChatCompletions } from "./chat-completions"; import { anthropicErrorResponse } from "../claude/outbound"; @@ -618,6 +619,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server { const url = new URL(req.url); markActivity(`${req.method} ${url.pathname}`); diff --git a/tests/server-body-limit.test.ts b/tests/server-body-limit.test.ts new file mode 100644 index 00000000000..efd5138e2ca --- /dev/null +++ b/tests/server-body-limit.test.ts @@ -0,0 +1,101 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { createConnection } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { saveConfig } from "../src/config"; +import { startServer } from "../src/server"; +import { MAX_DECOMPRESSED_BODY_BYTES } from "../src/server/request-decompress"; +import { installIsolatedCodexHome, type IsolatedCodexHome } from "./helpers/isolated-codex-home"; +import { INTERNAL_DEADLINE_MS, SERVER_BUDGET_MS } from "./helpers/test-budget"; + +const previousHome = process.env.CODEXCOMMANDER_HOME; +const previousApiToken = process.env.CODEXCOMMANDER_API_AUTH_TOKEN; +let testHome = ""; +let codexHome: IsolatedCodexHome; + +beforeEach(() => { + testHome = mkdtempSync(join(tmpdir(), "ccx-server-body-limit-")); + process.env.CODEXCOMMANDER_HOME = testHome; + process.env.CODEXCOMMANDER_API_AUTH_TOKEN = "body-limit-test-token"; + codexHome = installIsolatedCodexHome("ccx-body-limit-codex-"); + saveConfig({ + port: 0, + hostname: "0.0.0.0", + multiAgentGuidanceEnabled: true, + clientIntegrations: { codex: false }, + defaultProvider: "test", + providers: { + test: { adapter: "openai-chat", baseUrl: "https://example.test/v1", disabled: true }, + }, + }); +}); + +afterEach(() => { + if (previousHome === undefined) delete process.env.CODEXCOMMANDER_HOME; + else process.env.CODEXCOMMANDER_HOME = previousHome; + if (previousApiToken === undefined) delete process.env.CODEXCOMMANDER_API_AUTH_TOKEN; + else process.env.CODEXCOMMANDER_API_AUTH_TOKEN = previousApiToken; + codexHome.restore(); + rmSync(testHome, { recursive: true, force: true }); +}); + +function requestWithDeclaredBodySize(port: number, bytes: number): Promise<{ status: number; raw: string }> { + return new Promise((resolve, reject) => { + let raw = ""; + let settled = false; + const finish = (error?: Error) => { + if (settled) return; + settled = true; + socket.destroy(); + if (error) return reject(error); + const status = Number(raw.match(/^HTTP\/1\.[01] (\d{3})/)?.[1] ?? 0); + resolve({ status, raw }); + }; + const socket = createConnection({ host: "127.0.0.1", port }, () => { + // Header-only requests let Bun enforce its real ingress limit without allocating + // hundreds of MiB. Below that limit, the unchanged auth gate responds before reading. + socket.write([ + "POST /v1/responses HTTP/1.1", + `Host: 127.0.0.1:${port}`, + "Content-Type: application/json", + `Content-Length: ${bytes}`, + "Connection: close", + "", + "", + ].join("\r\n")); + }); + socket.setEncoding("utf8"); + socket.setTimeout(INTERNAL_DEADLINE_MS, () => finish(new Error("body-limit request timed out"))); + socket.on("data", chunk => { raw += chunk; }); + socket.on("end", () => finish()); + socket.on("error", error => finish(error)); + }); +} + +describe("real server HTTP ingress body limit", () => { + test.each([ + ["above Bun's 128 MiB default", 128 * 1024 * 1024 + 1], + ["exactly at the shared cap", MAX_DECOMPRESSED_BODY_BYTES], + ])("admits declared bodies %s to the application handler", async (_label, bytes) => { + const server = startServer(0); + try { + const response = await requestWithDeclaredBodySize(server.port!, bytes); + expect(response.status).toBe(401); + expect(response.raw).toContain('"type":"authentication_error"'); + } finally { + await server.stop(true); + } + }, SERVER_BUDGET_MS); + + test("rejects a declared body one byte above the shared cap at ingress", async () => { + const server = startServer(0); + try { + const response = await requestWithDeclaredBodySize(server.port!, MAX_DECOMPRESSED_BODY_BYTES + 1); + expect(response.status).toBe(413); + expect(response.raw).not.toContain('"type":"authentication_error"'); + } finally { + await server.stop(true); + } + }, SERVER_BUDGET_MS); +});