Skip to content
This repository was archived by the owner on Oct 11, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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).
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
2 changes: 1 addition & 1 deletion docs/behavior.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
2 changes: 1 addition & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ export function loadConfig(env: Record<string, string | undefined>): 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"),
Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
10 changes: 5 additions & 5 deletions src/plugin-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Uint8Array> {
async function boundedBody(request: Request, maxBytes: number | undefined, signal: AbortSignal): Promise<Uint8Array> {
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;
Expand All @@ -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;
Expand All @@ -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");
Expand Down
4 changes: 2 additions & 2 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" });
Expand All @@ -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");
Expand Down
3 changes: 2 additions & 1 deletion test/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
22 changes: 22 additions & 0 deletions test/plugin-v2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
Loading