diff --git a/.env.example b/.env.example index f8fa07a..1a42423 100644 --- a/.env.example +++ b/.env.example @@ -17,7 +17,7 @@ JEV_ROUTER_MAX_RETRIES=1 JEV_ROUTER_FALLBACK_MODE=fixed JEV_ROUTER_FALLBACK_EFFORT=high # JEV_ROUTER_DECISIONS_LOG_PATH=/absolute/path/to/jev-decisions.jsonl -JEV_ROUTER_MAX_REQUEST_BYTES=1048576 +JEV_ROUTER_MAX_REQUEST_BYTES=33554432 JEV_ROUTER_MAX_IN_FLIGHT=32 JEV_ROUTER_UPSTREAM_HEADER_TIMEOUT_MS=10000 JEV_ROUTER_UPSTREAM_IDLE_TIMEOUT_MS=60000 diff --git a/CHANGELOG.md b/CHANGELOG.md index 5df60e4..ea02556 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 0.6.3 + +- Remove the plugin's default request-size limit (`maxRequestBytes` is opt-in), fixing `413 request_too_large` when pasting images. +- Raise the proxy `JEV_ROUTER_MAX_REQUEST_BYTES` default from 1 MiB to 32 MiB. + ## 0.6.2 - Add GPT-6.1 Sol (`gpt-6.1-sol`) to the OpenAI profiles, with `low` through `max` efforts (`none` is not supported). diff --git a/README.md b/README.md index 3f6eec1..ccb4f0f 100644 --- a/README.md +++ b/README.md @@ -210,7 +210,7 @@ Plugin options go in `options` of a `plugins` entry. | `maxRetries`* | `1` | Extra attempts after transient Jev errors. | | `fallbackMode`* | `fixed` | `fixed`, `previous`, or `error` (fail the request). | | `fallbackEffort`* | `high` | Effort used when classification fails. | -| `maxRequestBytes` | `1048576` | Largest request body. | +| `maxRequestBytes` | unset (unlimited) | Optional maximum request-body bytes; must be a positive integer. | | `maxInFlight` | `32` | Concurrent requests. | | `upstreamHeaderTimeoutMs` | `10000` | Wait for endpoint response headers. | | `upstreamIdleTimeoutMs` | `60000` | Longest gap between streamed chunks. | diff --git a/docs/behavior.md b/docs/behavior.md index fe3d9b7..d90fc17 100644 --- a/docs/behavior.md +++ b/docs/behavior.md @@ -200,7 +200,7 @@ All limits are positive integers configured through environment variables: | Variable | Default | Behavior | | --- | ---: | --- | -| `JEV_ROUTER_MAX_REQUEST_BYTES` | 1048576 (1 MiB) | Maximum JSON request-body bytes; larger `POST /v1/responses` returns `413` with `{"error":"request_too_large"}`. Counts bytes, including chunked uploads. | +| `JEV_ROUTER_MAX_REQUEST_BYTES` | 33554432 (32 MiB) | Maximum JSON request-body bytes; larger `POST /v1/responses` returns `413` with `{"error":"request_too_large"}`. Counts bytes, including chunked uploads. | | `JEV_ROUTER_MAX_IN_FLIGHT` | 32 | Concurrent `/v1/responses` and `/v1/models` requests, including body reading, classification and forwarding; excess returns `503` with `{"error":"overloaded"}` before Jev/upstream work. | | `JEV_ROUTER_UPSTREAM_HEADER_TIMEOUT_MS` | 10000 | Deadline from upstream request start until response headers. | | `JEV_ROUTER_UPSTREAM_IDLE_TIMEOUT_MS` | 60000 | Maximum gap between upstream response chunks after headers; resets on each chunk and pauses while downstream backpressure pauses upstream reads. No total stream deadline is imposed. | diff --git a/package-lock.json b/package-lock.json index 126f2dd..fdf9977 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@robertn702/opencode-jev-router", - "version": "0.6.2", + "version": "0.6.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@robertn702/opencode-jev-router", - "version": "0.6.2", + "version": "0.6.3", "license": "MIT", "dependencies": { "@typesafe-ai/sdk": "^0.6.0" diff --git a/package.json b/package.json index 79a8cad..c6a1d57 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@robertn702/opencode-jev-router", - "version": "0.6.2", + "version": "0.6.3", "description": "Adaptive reasoning effort for OpenCode V2 with request-local GPT-6 model selection", "type": "module", "private": false, diff --git a/src/config.ts b/src/config.ts index 3773481..dd86551 100644 --- a/src/config.ts +++ b/src/config.ts @@ -178,7 +178,7 @@ export function loadConfig(env: Record): AppConfig { : { policy }, baseEffort: parseEffort(env.JEV_ROUTER_BASE_EFFORT), jevTimeoutMs: parseTimeout(env.JEV_ROUTER_CLASSIFICATION_TIMEOUT_MS), - maxRequestBytes: positiveInteger(env.JEV_ROUTER_MAX_REQUEST_BYTES, 1_048_576, "JEV_ROUTER_MAX_REQUEST_BYTES"), + maxRequestBytes: positiveInteger(env.JEV_ROUTER_MAX_REQUEST_BYTES, 33_554_432, "JEV_ROUTER_MAX_REQUEST_BYTES"), maxInFlight: positiveInteger(env.JEV_ROUTER_MAX_IN_FLIGHT, 32, "JEV_ROUTER_MAX_IN_FLIGHT"), upstreamHeaderTimeoutMs: positiveInteger(env.JEV_ROUTER_UPSTREAM_HEADER_TIMEOUT_MS, 10_000, "JEV_ROUTER_UPSTREAM_HEADER_TIMEOUT_MS"), upstreamIdleTimeoutMs: positiveInteger(env.JEV_ROUTER_UPSTREAM_IDLE_TIMEOUT_MS, 60_000, "JEV_ROUTER_UPSTREAM_IDLE_TIMEOUT_MS"), diff --git a/src/index.ts b/src/index.ts index 728299d..8dff2d9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -28,7 +28,7 @@ Environment: JEV_ROUTER_FALLBACK_MODE fixed (default), previous, or error JEV_ROUTER_FALLBACK_EFFORT Backup effort (default: high) JEV_ROUTER_DECISIONS_LOG_PATH Optional absolute path for local decision JSONL - JEV_ROUTER_MAX_REQUEST_BYTES Maximum POST body bytes (default: 1048576) + JEV_ROUTER_MAX_REQUEST_BYTES Maximum POST body bytes (default: 33554432) JEV_ROUTER_MAX_IN_FLIGHT Maximum active proxy requests (default: 32) JEV_ROUTER_UPSTREAM_HEADER_TIMEOUT_MS Upstream header deadline (default: 10000) JEV_ROUTER_UPSTREAM_IDLE_TIMEOUT_MS Upstream response idle deadline (default: 60000) diff --git a/src/plugin-runtime.ts b/src/plugin-runtime.ts index adfaf49..93f57a7 100644 --- a/src/plugin-runtime.ts +++ b/src/plugin-runtime.ts @@ -69,9 +69,9 @@ const checkUpstreamURL = (url: URL, name: string): void => { if (url.protocol !== "https:" && !loopback) throw new Error(`${name} requires HTTPS except for loopback endpoints`); }; -async function boundedBody(request: Request, maxBytes: number, signal: AbortSignal): Promise { +async function boundedBody(request: Request, maxBytes: number | undefined, signal: AbortSignal): Promise { const declared = request.headers.get("content-length"); - if (declared !== null && (!/^\d+$/.test(declared) || Number(declared) > maxBytes)) throw new RangeError("request_too_large"); + if (maxBytes !== undefined && declared !== null && (!/^\d+$/.test(declared) || Number(declared) > maxBytes)) throw new RangeError("request_too_large"); if (request.body === null) return new Uint8Array(); const reader = request.body.getReader(); const chunks: Uint8Array[] = []; let size = 0; @@ -81,10 +81,10 @@ async function boundedBody(request: Request, maxBytes: number, signal: AbortSign const next = await Promise.race([reader.read(), aborted]); if (next.done) break; size += next.value.byteLength; - if (size > maxBytes) throw new RangeError("request_too_large"); + if (maxBytes !== undefined && size > maxBytes) throw new RangeError("request_too_large"); chunks.push(next.value); } - } finally { if (signal.aborted || size > maxBytes) await reader.cancel().catch(() => undefined); } + } finally { if (signal.aborted || (maxBytes !== undefined && size > maxBytes)) await reader.cancel().catch(() => undefined); } const output = new Uint8Array(size); let at = 0; for (const chunk of chunks) { output.set(chunk, at); at += chunk.byteLength; } return output; @@ -97,7 +97,7 @@ export function createPluginRuntime(options: PluginOptions): PluginRuntime { if (options.jevModel !== undefined && options.jevModel !== connection?.model) throw new Error("jevModel must match the configured Jev endpoint"); if (options.baseEffort !== undefined && !["low", "medium", "high", "xhigh", "max"].includes(options.baseEffort)) throw new Error("baseEffort is unsupported"); if (options.fixedEffort !== undefined && !MODELS.every((model) => supportsEffort(model, options.fixedEffort))) throw new Error("fixedEffort must be supported by every model"); - const maxBytes = positive(options.maxRequestBytes, 1_048_576, "maxRequestBytes"); + const maxBytes = options.maxRequestBytes === undefined ? undefined : positive(options.maxRequestBytes, 1, "maxRequestBytes"); const maxInFlight = positive(options.maxInFlight, 32, "maxInFlight"); const headerTimeoutMs = positive(options.upstreamHeaderTimeoutMs, 10_000, "upstreamHeaderTimeoutMs"); const idleTimeoutMs = positive(options.upstreamIdleTimeoutMs, 60_000, "upstreamIdleTimeoutMs"); diff --git a/src/server.ts b/src/server.ts index 6392e1a..4ffc4f8 100644 --- a/src/server.ts +++ b/src/server.ts @@ -257,7 +257,7 @@ async function handle( "anthropic-version": anthropicVersion(typeof request.headers["anthropic-version"] === "string" ? request.headers["anthropic-version"] : undefined), "anthropic-beta": mergeAnthropicBeta(typeof request.headers["anthropic-beta"] === "string" ? request.headers["anthropic-beta"] : undefined), } : undefined; - if (Number(request.headers["content-length"]) > (options.maxRequestBytes ?? 1_048_576)) { + if (Number(request.headers["content-length"]) > (options.maxRequestBytes ?? 33_554_432)) { request.pause(); response.setHeader("connection", "close"); writeJson(response, 413, { error: "request_too_large" }); @@ -267,7 +267,7 @@ async function handle( } let raw: string; try { - raw = await readBody(request, options.maxRequestBytes ?? 1_048_576); + raw = await readBody(request, options.maxRequestBytes ?? 33_554_432); } catch (error) { if (!(error instanceof BodyTooLargeError)) throw error; response.setHeader("connection", "close"); diff --git a/test/config.test.ts b/test/config.test.ts index 1f2c71f..2e66898 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -40,7 +40,8 @@ describe("Jev connection", () => { describe("resource limit configuration", () => { it("uses bounded defaults and accepts positive overrides", () => { const defaults = load({}); - expect(defaults.maxRequestBytes).toBe(1_048_576); + expect(defaults.maxRequestBytes).toBe(33_554_432); + expect(load({ JEV_ROUTER_MAX_REQUEST_BYTES: "1024" }).maxRequestBytes).toBe(1024); expect(defaults.maxInFlight).toBe(32); expect(defaults.upstreamHeaderTimeoutMs).toBe(10_000); expect(defaults.upstreamIdleTimeoutMs).toBe(60_000); diff --git a/test/plugin-v2.test.ts b/test/plugin-v2.test.ts index 8cf2ea8..7846f5b 100644 --- a/test/plugin-v2.test.ts +++ b/test/plugin-v2.test.ts @@ -283,6 +283,28 @@ describe("V2 wrap aliases", () => { await reader.cancel(); expect(cancelled).toBe(true); h.cleanup(); }); + it.each([undefined, 1_048_576])("handles bodies larger than 1 MiB with maxRequestBytes=%s", async (maxRequestBytes) => { + const text = "x".repeat(1_048_577); + const data = { ...body, input: [{ type: "message", role: "user", content: [{ type: "input_text", text }] }] }; + const fetcher = vi.fn(async (request: Request) => { + expect((await request.json()).input.at(-1).content[0].text).toBe(text); + return new Response("{}", { headers: { "content-type": "application/json" } }); + }); globalThis.fetch = fetcher as typeof fetch; + const h = await host({ ...base, maxRequestBytes }); + if (maxRequestBytes === undefined) { + await (await h.exchange(data, { headers: { "content-length": String(Buffer.byteLength(JSON.stringify(data))) } })).response.text(); + expect(fetcher).toHaveBeenCalledOnce(); + } else { + await expect(h.exchange(data)).rejects.toThrow("request_too_large (413)"); + expect(fetcher).not.toHaveBeenCalled(); + } + h.cleanup(); + }); + + it.each([0, -1, 1.5, Infinity, NaN])("rejects invalid maxRequestBytes=%s", async (maxRequestBytes) => { + await expect(host({ ...base, maxRequestBytes })).rejects.toThrow("maxRequestBytes"); + }); + it("limits actual request bytes before classification", async () => { const fetcher = vi.fn(); globalThis.fetch = fetcher as typeof fetch; const h = await host({ ...base, maxRequestBytes: 16 });