Skip to content
Draft
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
68 changes: 66 additions & 2 deletions src/responses/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -724,6 +724,45 @@ function inputItems(input: unknown): unknown[] {
return [input];
}

/**
* Canonical identity used by replay-overlap detection. Volatile fields that differ between a
* stored response item and the client's later input resend (`id`, `status`, sequence numbers)
* are ignored; the remaining shape is what identifies "the same history item".
*/
function canonicalReplayValue(value: unknown): unknown {
if (Array.isArray(value)) return value.map(canonicalReplayValue);
if (value && typeof value === "object") {
// Null prototype so an own JSON `__proto__` key survives as a serializable property
// instead of being treated as a prototype assignment.
const out: Record<string, unknown> = Object.create(null);
for (const key of Object.keys(value as Record<string, unknown>).sort()) {
out[key] = canonicalReplayValue((value as Record<string, unknown>)[key]);
}
return out;
}
return value;
}

function canonicalReplayItemKey(item: unknown): string | undefined {
if (!item || typeof item !== "object" || Array.isArray(item)) return undefined;
const { id: _id, status: _status, sequence_number: _sequenceNumber, ...rest } = item as Record<string, unknown>;
// Sort every retained key (including nested objects and arrays) so equivalent items
// produce the same canonical string regardless of the original property order.
return JSON.stringify(canonicalReplayValue(rest));
}

/** Longest leading run of stored history items already present at the start of the request input. */
function replayedPrefixOverlap(stored: unknown[], requestInput: unknown[]): number {
let n = 0;
while (n < stored.length && n < requestInput.length) {
const left = canonicalReplayItemKey(stored[n]);
const right = canonicalReplayItemKey(requestInput[n]);
if (left === undefined || left !== right) break;
n++;
}
return n;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

function pruneResponses(at = now()): void {
for (const [id, state] of states) {
if (at - state.createdAt > RESPONSE_TTL_MS) deleteEntry(id);
Expand Down Expand Up @@ -840,6 +879,13 @@ function materializeEntry(
return { ok: true, state };
}

/**
* Expand a chained /v1/responses request's `previous_response_id` into the full stored
* history when the request carries only a delta, and never duplicate history the request
* already carries (stateless upstreams force full-body resends). Returns a new body so
* callers can tell expansion happened; an overlap-only request keeps its own input
* untouched and marks the leading stored-length items as the replay prefix.
*/
export function expandPreviousResponseInput(body: unknown): unknown {
if (!body || typeof body !== "object" || Array.isArray(body)) return body;
const request = body as Record<string, unknown>;
Expand All @@ -854,11 +900,29 @@ export function expandPreviousResponseInput(body: unknown): unknown {
replayFailures.set(request, materialized.failure);
return body;
}
const storedItems = materialized.state.items;
const requestItems = inputItems(request.input);
// A chained turn may already carry the full conversation (stateless upstreams such as
// DeepSeek force the client to resend it every turn). Prepending the stored history to a
// full-body request duplicates it, and remembering that duplicated body makes the bloat
// sticky across turns: 1x -> 2x -> 3x -> ... (observed 1,333,682 input tokens on
// 2026-08-10, ~10x the real ~127k conversation). Detect the overlap: ONLY a complete
// canonical stored-prefix overlap keeps the request untouched. Request length is not proof
// of a full resend — a genuine delta can be as long as the stored history, and returning it
// unchanged would drop the required prefix. Delta turns prepend the stored history and
// append the ENTIRE request input: request items are never dropped, because a repeated
// `context_compaction` marker or an identical message is a new occurrence that must survive.
const overlap = replayedPrefixOverlap(storedItems, requestItems);
if (overlap >= storedItems.length) {
const full = { ...request };
replayedInputPrefixLengths.set(full, Math.min(storedItems.length, requestItems.length));
return full;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
const expanded = {
...request,
input: [...materialized.state.items, ...inputItems(request.input)],
input: [...storedItems, ...requestItems],
};
replayedInputPrefixLengths.set(expanded, materialized.state.items.length);
replayedInputPrefixLengths.set(expanded, storedItems.length);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return expanded;
}

Expand Down
33 changes: 33 additions & 0 deletions src/server/responses/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import {
import { isInjectionDebugEnabled } from "../../lib/debug-settings";
import { injectionDebugLog } from "../../lib/injection-debug-log";
import { resolveClientRetryAfter } from "../../lib/retry-after";
import { estimateTokens } from "../../lib/token-estimate";
import { enrichOpenCodeZenRateLimitMessage } from "../../providers/opencode-zen-rate-limit";
import { modelInList, namespacedToolName } from "../../types";
import type { AdapterEvent, OcxConfig, OcxParsedRequest, OcxProviderConfig, OcxProviderContinuationState, OcxUsage } from "../../types";
Expand Down Expand Up @@ -1621,6 +1622,38 @@ async function handleResponsesInner(
);
}

// Input-size guard: refuse to forward an input that exceeds the model's advertised context
// window. The client compacts well before this limit, so an oversized body means abnormal
// duplication (observed: a 4x replay expansion pushed a ~400k-token conversation to 1.6M).
// Forwarding it on Windows balloons bun RSS and can native-crash the whole proxy (upstream
// Bun memory bug, issue #314), taking every active thread down at once. Fail one request
// cleanly instead. Reuse the model/CJK-aware estimate that already drives usage and compact
// decisions; summing parts avoids materializing another copy of a multi-megabyte request.
const advertisedWindow = route.provider.modelContextWindows?.[route.modelId];
if (typeof advertisedWindow === "number" && advertisedWindow > 0) {
let estimatedInputTokens = 0;
for (const msg of parsed.context.messages) {
const content = msg.content;
if (typeof content === "string") {
estimatedInputTokens += estimateTokens(content, route.modelId);
} else if (Array.isArray(content)) {
for (const part of content) {
if (part && typeof part === "object" && typeof (part as { text?: unknown }).text === "string") {
estimatedInputTokens += estimateTokens((part as { text: string }).text, route.modelId);
}
}
}
}
if (estimatedInputTokens > advertisedWindow) {
return formatErrorResponse(
413,
"request_too_large",
`input (≈${estimatedInputTokens} tokens) exceeds ${route.modelId} context window (${advertisedWindow} tokens); refusing to forward`,
{ code: "input_context_window_exceeded" },
);
}
}

// Captured before normalization: whether the CLIENT asked for SSE. The
// transport-neutral upstream-streaming policy below may force a bounded JSON
// upstream for reliability (#875); the answer must then be reframed to SSE
Expand Down
110 changes: 110 additions & 0 deletions tests/responses-input-guard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
/**
* Regression coverage for the responses input-size guard: a request whose input
* exceeds the model's advertised context window must be rejected with a clean 413
* instead of being forwarded (forwarding a ~1.6M-token duplication on Windows
* ballooned bun RSS and native-crashed the whole proxy, issue #314).
*/
import { afterEach, beforeEach, describe, expect, setDefaultTimeout, test } from "bun:test";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { handleResponses } from "../src/server/responses";
import type { OcxConfig } from "../src/types";
import type { RequestLogContext } from "../src/server/request-log";

setDefaultTimeout(30_000);

const originalFetch = globalThis.fetch;
let testDir: string;
let previousOpencodexHome: string | undefined;
let previousCodexHome: string | undefined;

beforeEach(() => {
testDir = mkdtempSync(join(tmpdir(), "ocx-input-guard-"));
previousOpencodexHome = process.env.OPENCODEX_HOME;
previousCodexHome = process.env.CODEX_HOME;
process.env.OPENCODEX_HOME = testDir;
process.env.CODEX_HOME = testDir;
});

afterEach(() => {
globalThis.fetch = originalFetch;
rmSync(testDir, { recursive: true, force: true });
if (previousOpencodexHome === undefined) delete process.env.OPENCODEX_HOME;
else process.env.OPENCODEX_HOME = previousOpencodexHome;
if (previousCodexHome === undefined) delete process.env.CODEX_HOME;
else process.env.CODEX_HOME = previousCodexHome;
});

function deepseekConfig(): OcxConfig {
return {
port: 0,
defaultProvider: "deepseek",
providers: {
deepseek: {
adapter: "openai-responses",
baseUrl: "https://api.deepseek.com",
responsesPath: "/responses",
authMode: "key",
apiKey: "sk-test",
models: ["deepseek-v4-flash"],
modelContextWindows: { "deepseek-v4-flash": 1_000_000 },
},
},
} as OcxConfig;
}

async function postResponses(config: OcxConfig, body: Record<string, unknown>): Promise<Response> {
return handleResponses(
new Request("http://localhost/v1/responses", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
}),
config,
{ model: "", provider: "" } as RequestLogContext,
);
}

describe("responses input-size guard", () => {
test("rejects an input above the advertised context window without calling upstream", async () => {
let upstreamCalls = 0;
globalThis.fetch = (async () => {
upstreamCalls += 1;
return Response.json({
id: "resp_x",
object: "response",
status: "completed",
output: [],
usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 },
});
}) as typeof fetch;
// The shared DeepSeek estimator uses 3.5 chars/token, so this is above the 1M window.
const bigText = "a".repeat(4_200_000);
const res = await postResponses(deepseekConfig(), {
model: "deepseek/deepseek-v4-flash",
input: [{ role: "user", content: [{ type: "input_text", text: bigText }] }],
});
expect(res.status).toBe(413);
expect(upstreamCalls).toBe(0);
});

test("forwards an input within the window", async () => {
let upstreamCalls = 0;
globalThis.fetch = (async () => {
upstreamCalls += 1;
return Response.json({
id: "resp_x",
object: "response",
status: "completed",
output: [],
usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 },
});
}) as typeof fetch;
const res = await postResponses(deepseekConfig(), {
model: "deepseek/deepseek-v4-flash",
input: [{ role: "user", content: [{ type: "input_text", text: "hello" }] }],
});
expect(upstreamCalls).toBe(1);
});
});
Loading
Loading