Skip to content
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ to match, push a tag `vX.Y.Z`, and release.yml builds + pushes the GHCR
image and creates the GitHub release from this file.
-->

## [Unreleased]

### Added

- `POST /v1/responses` (the OpenAI Responses API) is redacted like `/v1/chat/completions`. Current OpenAI clients (`client.responses.create`, the Agents SDK, Codex) use it by default, and until now it passed through verbatim, so a modern OpenAI client pointed at cordon sent raw PII upstream with `X-Redacted: 0`. The request walk covers `instructions` (under `REDACT_SYSTEM`, with `system` and `developer` items), `input` as a string or an item list (`input_text` parts, `function_call` arguments as parsed JSON, `function_call_output` text) and flat function tool definitions; `input_image` and `input_file` parts are left untouched. Non-streaming replies restore `output_text` and `refusal` parts; streaming restores `response.output_text.delta` through the same hold-back buffer as chat, re-emitting each delta under its original `item_id` / `output_index` / `content_index`, and restores the full text carried by `output_text.done`, `content_part.done`, `output_item.done` and `response.completed`. Same modes, headers and audit record (provider `openai`). Sub-paths such as `/v1/responses/{id}` still pass through verbatim.

## [0.2.1] - 2026-09-11

### Changed
Expand Down
118 changes: 118 additions & 0 deletions _stub-upstream.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,22 @@ function collectText(body, provider) {
};
if (provider === "anthropic" && body.system) pushContent(body.system);
for (const m of body.messages || []) if (m.role === "user") pushContent(m.content);
// Responses API: `input` is a string or a list of items (messages, function calls, outputs).
if (typeof body.input === "string") parts.push(body.input);
else for (const item of body.input || []) {
if (item?.role === "user") pushContent(item.content);
if (item?.type === "function_call_output" && typeof item.output === "string") parts.push(item.output);
}
return parts.join(" ");
}

const responsesBody = (n, text) => ({
id: "resp_stub" + n, object: "response", model: "gpt-4o-mini", status: "completed",
output: [{ id: "msg_stub" + n, type: "message", role: "assistant", status: "completed",
content: [{ type: "output_text", text, annotations: [] }] }],
usage: { input_tokens: 10, output_tokens: 5, total_tokens: 15 }, _stub_call: n,
});

const openaiBody = (n, text) => ({
id: "stub-" + n, object: "chat.completion", model: "gpt-4o-mini",
choices: [{ index: 0, message: { role: "assistant", content: text }, finish_reason: "stop" }],
Expand Down Expand Up @@ -57,6 +70,104 @@ function streamOpenAI(res, text) {
res.write("data: [DONE]\n\n");
res.end();
}
function streamResponses(res, text, n) {
res.setHeader("content-type", "text/event-stream");
let seq = 0;
const f = (type, d) => res.write(`event: ${type}\ndata: ${JSON.stringify({ type, sequence_number: seq++, ...d })}\n\n`);
const addr = { item_id: "msg_stub" + n, output_index: 0, content_index: 0 };
const shell = (status) => ({ id: "resp_stub" + n, object: "response", model: "gpt-4o-mini", status, output: [] });
f("response.created", { response: shell("in_progress") });
f("response.in_progress", { response: shell("in_progress") });
f("response.output_item.added", { output_index: 0, item: { id: addr.item_id, type: "message", role: "assistant", status: "in_progress", content: [] } });
f("response.content_part.added", { ...addr, part: { type: "output_text", text: "", annotations: [] } });
for (const c of chunk3(text)) f("response.output_text.delta", { ...addr, delta: c });
f("response.output_text.done", { ...addr, text });
f("response.content_part.done", { ...addr, part: { type: "output_text", text, annotations: [] } });
const item = { id: addr.item_id, type: "message", role: "assistant", status: "completed", content: [{ type: "output_text", text, annotations: [] }] };
f("response.output_item.done", { output_index: 0, item });
f("response.completed", { response: { ...shell("completed"), output: [item], usage: { input_tokens: 10, output_tokens: 5, total_tokens: 15 } } });
res.end();
}
// Two content parts of ONE response, streaming at the same time: the deltas of
// output_index 0 and 1 alternate on the wire, which is what a Responses turn with
// more than one output item looks like. Only part 0 carries PII.
function streamResponsesInterleaved(res, text, n) {
res.setHeader("content-type", "text/event-stream");
let seq = 0;
const f = (type, d) => res.write(`event: ${type}\ndata: ${JSON.stringify({ type, sequence_number: seq++, ...d })}\n\n`);
const a = { item_id: "msg_stub" + n + "a", output_index: 0, content_index: 0 };
const b = { item_id: "msg_stub" + n + "b", output_index: 1, content_index: 0 };
const aText = text.replace("INTERLEAVE ", "");
// B's text is the SAME de-identified text the stub received, so it carries real
// placeholders and the cut below can land inside one.
const bText = "second part repeats " + aText;
const shell = (status) => ({ id: "resp_stub" + n, object: "response", model: "gpt-4o-mini", status, output: [] });
const item = (addr, t, status) => ({ id: addr.item_id, type: "message", role: "assistant", status, content: status === "completed" ? [{ type: "output_text", text: t, annotations: [] }] : [] });
f("response.created", { response: shell("in_progress") });
for (const [addr, t] of [[a, aText], [b, bText]]) {
f("response.output_item.added", { output_index: addr.output_index, item: item(addr, t, "in_progress") });
f("response.content_part.added", { ...addr, part: { type: "output_text", text: "", annotations: [] } });
}
// B goes first and stops PART-WAY THROUGH ITS PLACEHOLDER, then A runs to
// completion INCLUDING its output_item.done, and only then does B finish. Closing A
// must not end B's re-identifier while B still holds a half-written placeholder.
// The cut lands INSIDE B's placeholder: the text the stub echoes is already
// de-identified, so `<` is the placeholder's first character and B stops four
// characters in, holding a fragment no re-identifier can resolve yet.
const ac = chunk3(aText);
const cut = bText.indexOf("<") >= 0 ? bText.indexOf("<") + 4 : Math.ceil(bText.length / 2);
for (const c of chunk3(bText.slice(0, cut))) f("response.output_text.delta", { ...b, delta: c });
for (const c of ac) f("response.output_text.delta", { ...a, delta: c });
f("response.output_text.done", { ...a, text: aText });
f("response.content_part.done", { ...a, part: { type: "output_text", text: aText, annotations: [] } });
f("response.output_item.done", { output_index: a.output_index, item: item(a, aText, "completed") });
for (const c of chunk3(bText.slice(cut))) f("response.output_text.delta", { ...b, delta: c });
f("response.output_text.done", { ...b, text: bText });
f("response.content_part.done", { ...b, part: { type: "output_text", text: bText, annotations: [] } });
f("response.output_item.done", { output_index: b.output_index, item: item(b, bText, "completed") });
f("response.completed", { response: { ...shell("completed"), output: [item(a, aText, "completed"), item(b, bText, "completed")], usage: { input_tokens: 10, output_tokens: 5, total_tokens: 15 } } });
res.end();
}

// A Responses stream that just STOPS: deltas up to a point four characters inside a
// placeholder, then the socket ends with no output_text.done, no response.completed
// and no [DONE]. Whatever the re-identifier is holding has to reach the client anyway.
function streamResponsesTruncated(res, text, n) {
res.setHeader("content-type", "text/event-stream");
let seq = 0;
const f = (type, d) => res.write(`event: ${type}\ndata: ${JSON.stringify({ type, sequence_number: seq++, ...d })}\n\n`);
const addr = { item_id: "msg_stub" + n, output_index: 0, content_index: 0 };
const body = text.replace("TRUNCATE ", "");
const marks = [...body.matchAll(/</g)].map((m) => m.index);
const cut = marks.length > 1 ? marks[1] + 4 : Math.ceil(body.length / 2);
f("response.created", { response: { id: "resp_stub" + n, object: "response", model: "gpt-4o-mini", status: "in_progress", output: [] } });
f("response.output_item.added", { output_index: 0, item: { id: addr.item_id, type: "message", role: "assistant", status: "in_progress", content: [] } });
f("response.content_part.added", { ...addr, part: { type: "output_text", text: "", annotations: [] } });
for (const c of chunk3(body.slice(0, cut))) f("response.output_text.delta", { ...addr, delta: c });
res.end();
}

// A REFUSED Responses turn streams the same frame shape under refusal-flavoured
// event types (`response.refusal.delta` / `.done`, a `refusal` content part). A
// client consuming refusals reads only those, so cordon must re-emit a restored
// refusal delta as a refusal delta — not as output text.
function streamResponsesRefusal(res, text, n) {
res.setHeader("content-type", "text/event-stream");
let seq = 0;
const f = (type, d) => res.write(`event: ${type}\ndata: ${JSON.stringify({ type, sequence_number: seq++, ...d })}\n\n`);
const addr = { item_id: "msg_stub" + n, output_index: 0, content_index: 0 };
const shell = (status) => ({ id: "resp_stub" + n, object: "response", model: "gpt-4o-mini", status, output: [] });
f("response.created", { response: shell("in_progress") });
f("response.output_item.added", { output_index: 0, item: { id: addr.item_id, type: "message", role: "assistant", status: "in_progress", content: [] } });
f("response.content_part.added", { ...addr, part: { type: "refusal", refusal: "" } });
for (const c of chunk3(text)) f("response.refusal.delta", { ...addr, delta: c });
f("response.refusal.done", { ...addr, refusal: text });
f("response.content_part.done", { ...addr, part: { type: "refusal", refusal: text } });
const item = { id: addr.item_id, type: "message", role: "assistant", status: "completed", content: [{ type: "refusal", refusal: text }] };
f("response.output_item.done", { output_index: 0, item });
f("response.completed", { response: { ...shell("completed"), output: [item], usage: { input_tokens: 10, output_tokens: 5, total_tokens: 15 } } });
res.end();
}
function streamAnthropic(res, text, body = {}) {
res.setHeader("content-type", "text/event-stream");
const f = (e, d) => res.write(`event: ${e}\ndata: ${JSON.stringify({ type: e, ...d })}\n\n`);
Expand Down Expand Up @@ -109,6 +220,13 @@ http
// Echo the received text back as the assistant reply.
if (req.url.includes("/chat/completions"))
return body.stream ? streamOpenAI(res, text) : json(res, openaiBody(n, text));
if (req.url.includes("/responses"))
return body.stream
? text.includes("TRUNCATE") ? streamResponsesTruncated(res, text, n)
: text.includes("FORCE_REFUSAL") ? streamResponsesRefusal(res, text, n)
: text.includes("INTERLEAVE") ? streamResponsesInterleaved(res, text, n)
: streamResponses(res, text, n)
: json(res, responsesBody(n, text));
if (req.url.includes("/messages"))
return body.stream ? streamAnthropic(res, text, body) : json(res, anthropicBody(n, text));
res.statusCode = 404;
Expand Down
83 changes: 83 additions & 0 deletions _test_proxy.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ const post = postTo(BASE);
const post2 = postTo(BASE2);
const aBody = (text, extra = {}) => ({ model: "claude-haiku-4-5", messages: [{ role: "user", content: text }], ...extra });
const oBody = (text, extra = {}) => ({ model: "gpt-4o-mini", messages: [{ role: "user", content: text }], ...extra });
const rBody = (input, extra = {}) => ({ model: "gpt-4o-mini", input, ...extra });
const rText = (j) => j?.output?.[0]?.content?.[0]?.text || "";
const setTenantOn = (base) => (patch) =>
fetch(base + "/admin/tenant", { method: "POST", headers: { "content-type": "application/json", "x-admin-token": ADMIN }, body: JSON.stringify(patch) });
const setTenant = setTenantOn(BASE);
Expand Down Expand Up @@ -82,6 +84,85 @@ const setTenant2 = setTenantOn(BASE2);
sent = JSON.stringify(await calls());
ok("off: upstream saw RAW (verbatim passthrough)", sent.includes("john@acme.com"));

// ---- reversible (OpenAI Responses API): string input ----
await reset();
res = await post("/v1/responses", rBody(PII));
text = rText(await res.json());
ok("responses/string: email restored in output_text", text.includes("john@acme.com") && !text.includes("<EMAIL"));
ok("responses/string: card restored in output_text", text.includes("4012888888881881"));
ok("responses/string: X-Redacted >= 2", Number(res.headers.get("x-redacted")) >= 2, res.headers.get("x-redacted"));
ok("responses/string: X-Redacted-Types lists EMAIL and CREDIT_CARD",
/EMAIL:1/.test(res.headers.get("x-redacted-types") || "") && /CREDIT_CARD:1/.test(res.headers.get("x-redacted-types") || ""),
res.headers.get("x-redacted-types"));
sent = JSON.stringify(await calls());
ok("responses/string: upstream saw placeholder", /<EMAIL_[0-9A-F]+_1>/.test(sent));
ok("responses/string: upstream NEVER saw raw PII", !sent.includes("john@acme.com") && !sent.includes("4012888888881881"));

// ---- reversible (Responses): item list with input_text parts, instructions, a function output ----
await reset();
res = await post("/v1/responses", rBody(
[
{ role: "user", content: [{ type: "input_text", text: PII }, { type: "input_image", image_url: "data:image/png;base64,AAAA" }] },
{ type: "function_call", call_id: "call_1", name: "lookup", arguments: JSON.stringify({ email: "jane@corp.io" }) },
{ type: "function_call_output", call_id: "call_1", output: "account for jane@corp.io: card 4012888888881881" },
],
{ instructions: "You help ops@acme.com triage tickets." },
));
text = rText(await res.json());
ok("responses/items: input_text restored in reply", text.includes("john@acme.com") && !text.includes("<EMAIL"));
const rsent = await calls();
sent = JSON.stringify(rsent);
ok("responses/items: upstream NEVER saw raw PII in any item", !sent.includes("john@acme.com") && !sent.includes("jane@corp.io") && !sent.includes("ops@acme.com") && !sent.includes("4012888888881881"));
ok("responses/items: input_image part forwarded untouched", sent.includes("data:image/png;base64,AAAA"));
{
const fc = rsent.bodies?.[0]?.body?.input?.find((i) => i.type === "function_call");
let args;
try { args = JSON.parse(fc?.arguments ?? ""); } catch {}
ok("responses/items: function_call arguments stay valid JSON with a placeholder", typeof args?.email === "string" && /^<EMAIL_/.test(args.email), fc?.arguments);
}
ok("responses/items: X-Redacted counts every field", Number(res.headers.get("x-redacted")) >= 5, res.headers.get("x-redacted"));

// ---- reversible (Responses): a prior assistant turn fed back as input ----
// The reply cordon restores carries real values, and a stateless client appends it to
// the next request's input. Those parts are output_text/refusal, not input_text.
await reset();
res = await post("/v1/responses", rBody([
{ role: "user", content: [{ type: "input_text", text: "who do I contact" }] },
{
type: "message", role: "assistant",
content: [
{ type: "output_text", text: "Contact john@acme.com about card 4012888888881881", annotations: [] },
{ type: "refusal", refusal: "I cannot share ops@acme.com" },
],
},
{ role: "user", content: [{ type: "input_text", text: "thanks" }] },
]));
sent = JSON.stringify(await calls());
ok("responses/history: assistant output_text never reaches upstream raw",
!sent.includes("john@acme.com") && !sent.includes("4012888888881881"), sent.slice(0, 200));
ok("responses/history: assistant refusal never reaches upstream raw", !sent.includes("ops@acme.com"));
ok("responses/history: the assistant turn is placeholdered", /<EMAIL_[0-9A-F]+_\d>/.test(sent));
ok("responses/history: X-Redacted counts the assistant turn", Number(res.headers.get("x-redacted")) >= 3, res.headers.get("x-redacted"));

// ---- strip / off (Responses) ----
await reset();
res = await post("/v1/responses", rBody(PII), { "x-redact-mode": "strip" });
text = rText(await res.json());
ok("responses/strip: placeholders persist (not restored)", text.includes("[EMAIL]") && !text.includes("john@acme.com"));
sent = JSON.stringify(await calls());
ok("responses/strip: upstream saw [EMAIL], not raw", sent.includes("[EMAIL]") && !sent.includes("john@acme.com"));
await reset();
res = await post("/v1/responses", rBody(PII), { "x-redact-mode": "off" });
text = rText(await res.json());
ok("responses/off: reply echoes raw (nothing redacted)", text.includes("john@acme.com"));
ok("responses/off: X-Redacted 0", res.headers.get("x-redacted") === "0");

// ---- fail-closed (Responses) ----
await reset();
res = await post("/v1/responses", rBody(PII), { "x-cordon-fail": "1" });
ok("responses/fail-closed: status 422", res.status === 422);
ok("responses/fail-closed: upstream NOT called", (await calls()).total === 0);

// ---- fail-closed ----
await reset();
res = await post("/v1/messages", aBody(PII), { "x-cordon-fail": "1" });
Expand Down Expand Up @@ -152,6 +233,8 @@ const setTenant2 = setTenantOn(BASE2);
ok("audit: log contains NO raw email", !log.includes("john@acme.com"));
ok("audit: log contains NO raw card", !log.includes("4012888888881881"));
ok("audit: log contains NO raw jane", !log.includes("jane@corp.io"));
ok("audit: log contains NO raw ops address from Responses instructions", !log.includes("ops@acme.com"));
ok("audit: Responses requests recorded under provider openai", log.split("\n").some((l) => l.includes('"provider":"openai"') && l.includes('"CREDIT_CARD":1')));

console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0);
Expand Down
Loading
Loading