client = OpenAI(base_url="http://localhost:3000/openai/v1")
```
+Both `client.chat.completions.create(...)` and `client.responses.create(...)` pass through PasteGuard's privacy pipeline.
+
For custom config, persistent logs, Docker Compose, or detector settings: **[Read the docs](https://pasteguard.com/docs/installation)**.
## Privacy Modes
---
title: OpenAI
-description: POST /openai/v1/chat/completions
+description: POST /openai/v1/chat/completions and /responses
---
-Generate chat completions with automatic PII and secrets protection.
+Generate Chat Completions and Responses with automatic PII and secrets protection.
```
POST /openai/v1/chat/completions
+POST /openai/v1/responses
```
<Note>
-This is the only endpoint that receives PII detection and masking. All other OpenAI endpoints (`/models`, `/embeddings`, `/files`, etc.) are proxied directly to OpenAI without modification.
+These endpoints receive PII detection and masking. Other OpenAI endpoints (`/models`, `/embeddings`, `/files`, etc.) are proxied directly without modification.
</Note>
-## Request
+## Chat Completions request
```bash
curl http://localhost:3000/openai/v1/chat/completions \
</CodeGroup>
+## Responses
+
+Use the same base URL with the Responses API:
+
+```bash
+curl http://localhost:3000/openai/v1/responses \
+ -H "Authorization: Bearer $OPENAI_API_KEY" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "model": "gpt-5.2",
+ "input": "Summarize this customer note"
+ }'
+```
+
+PasteGuard inspects Responses requests, restores supported JSON and streaming responses, and records masking events in the dashboard.
+
## Response Headers
PasteGuard adds headers to indicate PII and secrets handling:
<Note>In Docker Compose, use the service name instead of `localhost`, such as `http://pasteguard:3000/openai/v1`.</Note>
+### OpenRouter Responses pipe
+
+For an OpenWebUI pipe that calls OpenRouter through the Responses API, configure OpenRouter as PasteGuard's OpenAI provider:
+
+```yaml
+providers:
+ openai:
+ base_url: https://openrouter.ai/api/v1
+```
+
+Then set these pipe valves in Open WebUI:
+
+| Valve | Value |
+|-------|-------|
+| `BASE_URL` | `http://pasteguard:3000/openai/v1` |
+| `DEFAULT_LLM_ENDPOINT` | `responses` |
+
+Requests sent to `POST /openai/v1/responses` are inspected, restored, and shown in the dashboard.
+
## LibreChat
Add PasteGuard as a custom endpoint in your LibreChat configuration:
import { describe, expect, test } from "bun:test";
-import { type CodexResponsesRequest, codexExtractor } from "./codex";
+import { type ResponsesRequest, responsesExtractor } from "./responses";
-describe("Codex Text Extractor", () => {
+describe("Responses Text Extractor", () => {
test("infers roles for instructions, messages, tools, and MCP items", () => {
- const request: CodexResponsesRequest = {
+ const request: ResponsesRequest = {
model: "gpt-5.5",
instructions: "System Jane jane.system@example.com",
input: [
};
expect(
- codexExtractor.extractTexts(request).map((span) => ({
+ responsesExtractor.extractTexts(request).map((span) => ({
path: span.path,
role: span.role,
text: span.text,
import { type PlaceholderContext, restorePlaceholders } from "../../masking/context";
import type { MaskedSpan, RequestExtractor, TextSpan } from "../types";
-export type CodexResponsesRequest = {
+export type ResponsesRequest = {
model?: string;
- instructions?: string;
+ instructions?: unknown;
input?: unknown;
stream?: boolean;
[key: string]: unknown;
};
-export type CodexResponsesResponse = Record<string, unknown>;
+export type ResponsesResponse = Record<string, unknown>;
const TEXT_KEYS = new Set([
"arguments",
return result;
}
-export const codexExtractor: RequestExtractor<CodexResponsesRequest, CodexResponsesResponse> = {
- extractTexts(request: CodexResponsesRequest): TextSpan[] {
+export const responsesExtractor: RequestExtractor<ResponsesRequest, ResponsesResponse> = {
+ extractTexts(request: ResponsesRequest): TextSpan[] {
return collectText(request).map((item, index) => ({
text: item.value,
path: pathToString(item.path),
}));
},
- applyMasked(request: CodexResponsesRequest, maskedSpans: MaskedSpan[]): CodexResponsesRequest {
+ applyMasked(request: ResponsesRequest, maskedSpans: MaskedSpan[]): ResponsesRequest {
return maskedSpans.reduce(
(current, span) => setAtPath(current, pathFromString(span.path), span.maskedText),
request,
},
unmaskResponse(
- response: CodexResponsesResponse,
+ response: ResponsesResponse,
context: PlaceholderContext,
formatValue?: (original: string) => string,
- ): CodexResponsesResponse {
+ ): ResponsesResponse {
let result = response;
for (const item of collectText(response)) {
result = setAtPath(result, item.path, restorePlaceholders(item.value, context, formatValue));
import type { OpenAIResponse } from "../providers/openai/types";
import { createPlaceholderContext, type PlaceholderContext } from "./context";
import { anthropicExtractor } from "./extractors/anthropic";
-import { type CodexResponsesResponse, codexExtractor } from "./extractors/codex";
import { openaiExtractor } from "./extractors/openai";
+import { type ResponsesResponse, responsesExtractor } from "./extractors/responses";
import { restoreResponse } from "./restorer";
import type { RequestExtractor } from "./types";
});
test("Codex response", () => {
- const response: CodexResponsesResponse = {
+ const response: ResponsesResponse = {
output: [{ content: [{ type: "output_text", text: "Your key is [[API_KEY_SK_1]]" }] }],
};
- const result = restoreResponse(response, codexExtractor, markerConfig, {
+ const result = restoreResponse(response, responsesExtractor, markerConfig, {
secretsContext: context({ "[[API_KEY_SK_1]]": "sk-secret" }),
});
import { describe, expect, test } from "bun:test";
import type { MaskingConfig } from "../../config";
import { createPlaceholderContext, type PlaceholderContext } from "../../masking/context";
-import { createCodexUnmaskingStream } from "./stream-transformer";
+import { createResponsesUnmaskingStream } from "./stream-transformer";
const defaultConfig: MaskingConfig = {
show_markers: false,
return `data: ${JSON.stringify({ type: "response.output_text.delta", delta: text })}\n\n`;
}
-describe("createCodexUnmaskingStream", () => {
+describe("createResponsesUnmaskingStream", () => {
test("restores complete placeholders", async () => {
const piiContext = context({ "[[EMAIL_ADDRESS_1]]": "jane@example.com" });
const source = createSSEStream([codexDelta("Email [[EMAIL_ADDRESS_1]]")]);
const result = await consumeStream(
- createCodexUnmaskingStream(source, piiContext, defaultConfig),
+ createResponsesUnmaskingStream(source, piiContext, defaultConfig),
);
expect(result).toContain("Email jane@example.com");
const source = createSSEStream([codexDelta("Email [[EMAIL_"), codexDelta("ADDRESS_1]] done")]);
const result = await consumeStream(
- createCodexUnmaskingStream(source, piiContext, defaultConfig),
+ createResponsesUnmaskingStream(source, piiContext, defaultConfig),
);
expect(result).toContain("jane@example.com done");
const source = createSSEStream([codexDelta("[[PERSON_1]] used [[API_KEY_SK_1]]")]);
const result = await consumeStream(
- createCodexUnmaskingStream(
+ createResponsesUnmaskingStream(
source,
piiContext,
{ ...defaultConfig, show_markers: true },
const source = createSSEStream(["data: not-json\n\n", "data: [DONE]\n\n"]);
const result = await consumeStream(
- createCodexUnmaskingStream(source, undefined, defaultConfig),
+ createResponsesUnmaskingStream(source, undefined, defaultConfig),
);
expect(result).toContain("data: not-json");
const source = createSSEStream([codexDelta("Email [[EMAIL")]);
const result = await consumeStream(
- createCodexUnmaskingStream(source, piiContext, defaultConfig),
+ createResponsesUnmaskingStream(source, piiContext, defaultConfig),
);
expect(result).toContain('"type":"response.output_text.delta"');
import type { MaskingConfig } from "../../config";
import type { PlaceholderContext } from "../../masking/context";
-import { type CodexResponsesResponse, codexExtractor } from "../../masking/extractors/codex";
+import { type ResponsesResponse, responsesExtractor } from "../../masking/extractors/responses";
import { StreamRestorer } from "../../masking/stream-restorer";
-export function createCodexUnmaskingStream(
+export function createResponsesUnmaskingStream(
stream: ReadableStream<Uint8Array>,
piiContext: PlaceholderContext | undefined,
maskingConfig: MaskingConfig,
});
function unmaskPayload(payload: unknown): unknown {
- const result = payload as CodexResponsesResponse;
- const spans = codexExtractor.extractTexts(result);
+ const result = payload as ResponsesResponse;
+ const spans = responsesExtractor.extractTexts(result);
if (spans.length === 0) {
return result;
}
- return codexExtractor.applyMasked(
+ return responsesExtractor.applyMasked(
result,
spans.map((span) => ({
...span,
import { logRequest } from "../logging/logger";
import type { PlaceholderContext } from "../masking/context";
import {
- type CodexResponsesRequest,
- type CodexResponsesResponse,
- codexExtractor,
-} from "../masking/extractors/codex";
+ type ResponsesRequest as CodexResponsesRequest,
+ type ResponsesResponse as CodexResponsesResponse,
+ responsesExtractor,
+} from "../masking/extractors/responses";
import { restoreResponse } from "../masking/restorer";
import type { PIIDetectResult } from "../pii/request";
import {
type PrivacyPipelineResult,
processPrivacyPipeline,
} from "../privacy/pipeline";
-import { createCodexUnmaskingStream } from "../providers/codex/stream-transformer";
+import { createResponsesUnmaskingStream } from "../protocols/responses/stream-transformer";
import { ProviderError } from "../providers/errors";
import type { SecretsProcessResult } from "../secrets/request";
import {
let privacy: PrivacyPipelineResult<CodexResponsesRequest>;
try {
- privacy = await processPrivacyPipeline(request, config, codexExtractor);
+ privacy = await processPrivacyPipeline(request, config, responsesExtractor);
} catch (error) {
if (error instanceof PrivacyPipelineDetectionError) {
console.error("PII detection error:", error.cause ?? error);
function formatCodexForLog(request: CodexResponsesRequest): string | undefined {
const config = getConfig();
- return formatMaskedRequestForLog(request, codexExtractor, config);
+ return formatMaskedRequestForLog(request, responsesExtractor, config);
}
function respondBlocked(
setStreamingHeaders(c);
if (piiContext || secretsContext) {
- return c.body(createCodexUnmaskingStream(stream, piiContext, maskingConfig, secretsContext));
+ return c.body(
+ createResponsesUnmaskingStream(stream, piiContext, maskingConfig, secretsContext),
+ );
}
return c.body(stream);
secretsContext?: PlaceholderContext,
maskingConfig = getConfig().masking,
) {
- const result = restoreResponse(response, codexExtractor, maskingConfig, {
+ const result = restoreResponse(response, responsesExtractor, maskingConfig, {
piiContext,
secretsContext,
});
--- /dev/null
+import { afterEach, describe, expect, mock, test } from "bun:test";
+import { Hono } from "hono";
+import { getConfig } from "../config";
+import { getLogger, Logger, normalizeRequestSource } from "../logging/logger";
+import { filterAllowlistedEntities, type PIIDetectionResult, PIIDetector } from "../pii/detect";
+
+const noPII: PIIDetectionResult = {
+ hasPII: false,
+ spanEntities: [],
+ allEntities: [],
+ scanTimeMs: 0,
+};
+const mockAnalyzeRequest = mock<() => Promise<PIIDetectionResult>>(() => Promise.resolve(noPII));
+const mockLogRequest = mock(() => {});
+
+mock.module("../pii/detect", () => ({
+ PIIDetector,
+ filterAllowlistedEntities,
+ getPIIDetector: () => ({
+ analyzeRequest: mockAnalyzeRequest,
+ detectPII: mock(() => Promise.resolve([])),
+ healthCheck: mock(() => Promise.resolve(true)),
+ }),
+}));
+
+mock.module("../logging/logger", () => ({
+ getLogger,
+ Logger,
+ logRequest: mockLogRequest,
+ normalizeRequestSource,
+}));
+
+const { openaiRoutes } = await import("./openai");
+const app = new Hono();
+app.route("/openai", openaiRoutes);
+
+const originalFetch = globalThis.fetch;
+const config = getConfig();
+const originalMode = config.mode;
+const originalAPIKey = config.providers.openai.api_key;
+
+afterEach(() => {
+ globalThis.fetch = originalFetch;
+ config.mode = originalMode;
+ if (originalAPIKey) config.providers.openai.api_key = originalAPIKey;
+ else delete config.providers.openai.api_key;
+ mockAnalyzeRequest.mockClear();
+ mockAnalyzeRequest.mockResolvedValue(noPII);
+ mockLogRequest.mockClear();
+});
+
+function emailDetection(text: string, email: string): PIIDetectionResult {
+ const start = text.indexOf(email);
+ const entity = {
+ entity_type: "EMAIL_ADDRESS",
+ start,
+ end: start + email.length,
+ score: 0.99,
+ };
+ return {
+ hasPII: true,
+ spanEntities: [[entity]],
+ allEntities: [entity],
+ scanTimeMs: 2,
+ };
+}
+
+describe("POST /openai/v1/responses", () => {
+ test("protects the OpenWebUI/OpenRouter Responses request", async () => {
+ const email = "john@example.com";
+ const input = `Email ${email}`;
+ mockAnalyzeRequest.mockResolvedValueOnce(emailDetection(input, email));
+
+ let upstream: Request | undefined;
+ globalThis.fetch = (async (target: string | URL | Request, init?: RequestInit) => {
+ upstream = target instanceof Request ? target : new Request(target, init);
+ return Response.json({
+ id: "resp_test",
+ output: [
+ {
+ type: "message",
+ content: [{ type: "output_text", text: "Handled [[EMAIL_ADDRESS_1]]" }],
+ },
+ ],
+ });
+ }) as typeof fetch;
+
+ const response = await app.request("/openai/v1/responses", {
+ method: "POST",
+ headers: {
+ Authorization: "Bearer openrouter-client-token",
+ "Content-Type": "application/json",
+ "HTTP-Referer": "https://openwebui.example",
+ "X-OpenRouter-Title": "OpenWebUI",
+ "X-Anthropic-Beta": "interleaved-thinking-2025-05-14",
+ "X-OpenWebUI-User-Email": "identity@example.com",
+ },
+ body: JSON.stringify({
+ model: "openai/gpt-test",
+ input,
+ plugins: [{ id: "web" }],
+ }),
+ });
+
+ expect(response.status).toBe(200);
+ expect(upstream?.url).toBe(`${config.providers.openai.base_url}/responses`);
+ expect(upstream?.headers.get("authorization")).toBe("Bearer openrouter-client-token");
+ expect(upstream?.headers.get("http-referer")).toBe("https://openwebui.example");
+ expect(upstream?.headers.get("x-openrouter-title")).toBe("OpenWebUI");
+ expect(upstream?.headers.get("x-anthropic-beta")).toBe("interleaved-thinking-2025-05-14");
+ expect(upstream?.headers.get("x-openwebui-user-email")).toBeNull();
+
+ const upstreamBody = (await upstream?.json()) as {
+ model: string;
+ input: string;
+ plugins: Array<{ id: string }>;
+ store: boolean;
+ };
+ expect(upstreamBody).toEqual({
+ model: "openai/gpt-test",
+ input: "Email [[EMAIL_ADDRESS_1]]",
+ plugins: [{ id: "web" }],
+ store: false,
+ });
+ expect(await response.json()).toEqual({
+ id: "resp_test",
+ output: [
+ {
+ type: "message",
+ content: [{ type: "output_text", text: `Handled ${email}` }],
+ },
+ ],
+ });
+ expect(response.headers.get("X-PasteGuard-PII-Masked")).toBe("true");
+ expect(mockLogRequest).toHaveBeenCalledWith(
+ expect.objectContaining({
+ provider: "openai",
+ statusCode: 200,
+ piiDetected: true,
+ maskedContent: expect.stringContaining("[[EMAIL_ADDRESS_1]]"),
+ }),
+ null,
+ );
+ });
+
+ test("restores placeholders split across SSE events", async () => {
+ const email = "stream@example.com";
+ const input = `Email ${email}`;
+ mockAnalyzeRequest.mockResolvedValueOnce(emailDetection(input, email));
+ globalThis.fetch = (async (_target: string | URL | Request, _init?: RequestInit) =>
+ new Response(
+ [
+ "event: response.output_text.delta",
+ `data: ${JSON.stringify({ type: "response.output_text.delta", delta: "Email [[EMAIL_" })}`,
+ "",
+ "event: response.output_text.delta",
+ `data: ${JSON.stringify({ type: "response.output_text.delta", delta: "ADDRESS_1]]" })}`,
+ "",
+ ].join("\n"),
+ { headers: { "Content-Type": "text/event-stream" } },
+ )) as typeof fetch;
+
+ const response = await app.request("/openai/v1/responses", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ model: "openai/gpt-test", input, stream: true }),
+ });
+
+ const body = await response.text();
+ expect(response.status).toBe(200);
+ expect(response.headers.get("Content-Type")).toContain("text/event-stream");
+ expect(body).toContain(email);
+ expect(body).not.toContain("[[EMAIL_ADDRESS_1]]");
+ });
+
+ test("remasks known values in restored assistant history", async () => {
+ const email = "history@example.com";
+ const userText = `My email is ${email}`;
+ const detection = emailDetection(userText, email);
+ detection.spanEntities.push([], []);
+ mockAnalyzeRequest.mockResolvedValueOnce(detection);
+
+ let upstreamBody: Record<string, unknown> | undefined;
+ globalThis.fetch = (async (_target: string | URL | Request, init?: RequestInit) => {
+ upstreamBody = JSON.parse(String(init?.body));
+ return Response.json({ id: "resp_history", output: [] });
+ }) as typeof fetch;
+
+ const response = await app.request("/openai/v1/responses", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ model: "openai/gpt-test",
+ input: [
+ {
+ type: "message",
+ role: "user",
+ content: [{ type: "input_text", text: userText }],
+ },
+ {
+ type: "message",
+ role: "assistant",
+ content: [{ type: "output_text", text: `Got it: ${email}` }],
+ },
+ {
+ type: "message",
+ role: "user",
+ content: [{ type: "input_text", text: "Continue" }],
+ },
+ ],
+ }),
+ });
+
+ expect(response.status).toBe(200);
+ const serialized = JSON.stringify(upstreamBody);
+ expect(serialized).not.toContain(email);
+ expect(serialized.match(/\[\[EMAIL_ADDRESS_1\]\]/g)).toHaveLength(2);
+ });
+
+ test("blocks stateful options when values were masked", async () => {
+ const email = "state@example.com";
+ const input = `Email ${email}`;
+ mockAnalyzeRequest.mockResolvedValue(emailDetection(input, email));
+ let fetchCalls = 0;
+ globalThis.fetch = (async (_target: string | URL | Request, _init?: RequestInit) => {
+ fetchCalls++;
+ return Response.json({ output: [] });
+ }) as typeof fetch;
+
+ const options = [
+ { background: true },
+ { conversation: "conv_123" },
+ { previous_response_id: "resp_123" },
+ { context_management: [{ type: "compaction", compact_threshold: 1000 }] },
+ { store: true },
+ ];
+
+ for (const option of options) {
+ const response = await app.request("/openai/v1/responses", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ model: "openai/gpt-test", input, ...option }),
+ });
+ expect(response.status).toBe(400);
+ expect(await response.json()).toEqual(
+ expect.objectContaining({
+ error: expect.objectContaining({ code: "stateful_responses_not_supported" }),
+ }),
+ );
+ }
+ expect(fetchCalls).toBe(0);
+ });
+
+ test("uses the configured API key when client auth is absent", async () => {
+ config.providers.openai.api_key = "sk-config-fallback";
+ let upstreamHeaders = new Headers();
+ globalThis.fetch = (async (target: string | URL | Request, init?: RequestInit) => {
+ const request = target instanceof Request ? target : new Request(target, init);
+ upstreamHeaders = request.headers;
+ return Response.json({ output: [] });
+ }) as typeof fetch;
+
+ const response = await app.request("/openai/v1/responses", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ model: "openai/gpt-test", input: "Reply ok" }),
+ });
+
+ expect(response.status).toBe(200);
+ expect(upstreamHeaders.get("authorization")).toBe("Bearer sk-config-fallback");
+ });
+
+ test("rejects excessively nested requests before forwarding", async () => {
+ let input: Record<string, unknown> = { text: "hello" };
+ for (let depth = 0; depth < 140; depth++) input = { nested: input };
+ let fetchCalled = false;
+ globalThis.fetch = (async (_target: string | URL | Request, _init?: RequestInit) => {
+ fetchCalled = true;
+ return Response.json({ output: [] });
+ }) as typeof fetch;
+
+ const response = await app.request("/openai/v1/responses", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ input }),
+ });
+
+ expect(response.status).toBe(400);
+ expect(fetchCalled).toBe(false);
+ });
+});
+
+describe("OpenAI passthrough boundary", () => {
+ test("blocks deeply encoded aliases of the protected Responses endpoint", async () => {
+ let fetchCalled = false;
+ globalThis.fetch = (async (_target: string | URL | Request, _init?: RequestInit) => {
+ fetchCalled = true;
+ return Response.json({ output: [] });
+ }) as typeof fetch;
+
+ const response = await app.request("/openai/v1/%25252572esponses", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ input: "Email raw@example.com" }),
+ });
+
+ expect(response.status).toBe(404);
+ expect(fetchCalled).toBe(false);
+ expect(mockAnalyzeRequest).not.toHaveBeenCalled();
+ });
+
+ test("keeps model discovery on the existing passthrough", async () => {
+ let upstream: Request | undefined;
+ globalThis.fetch = (async (target: string | URL | Request, init?: RequestInit) => {
+ upstream = target instanceof Request ? target : new Request(target, init);
+ return Response.json({ data: [] });
+ }) as typeof fetch;
+
+ const response = await app.request("/openai/v1/models", {
+ headers: { Authorization: "Bearer openrouter-client-token" },
+ });
+
+ expect(response.status).toBe(200);
+ expect(upstream?.url).toBe(`${config.providers.openai.base_url}/models`);
+ expect(upstream?.headers.get("authorization")).toBe("Bearer openrouter-client-token");
+ expect(mockAnalyzeRequest).not.toHaveBeenCalled();
+ expect(mockLogRequest).not.toHaveBeenCalled();
+ });
+});
--- /dev/null
+import { zValidator } from "@hono/zod-validator";
+import type { Context } from "hono";
+import { Hono } from "hono";
+import { z } from "zod";
+import { getConfig, type MaskingConfig, type OpenAIProviderConfig } from "../config";
+import { formatMaskedRequestForLog } from "../logging/log-content";
+import { logRequest } from "../logging/logger";
+import type { PlaceholderContext } from "../masking/context";
+import {
+ type ResponsesRequest,
+ type ResponsesResponse,
+ responsesExtractor,
+} from "../masking/extractors/responses";
+import { restoreResponse } from "../masking/restorer";
+import type { PIIDetectResult } from "../pii/request";
+import {
+ PrivacyPipelineDetectionError,
+ type PrivacyPipelineResult,
+ processPrivacyPipeline,
+} from "../privacy/pipeline";
+import { createResponsesUnmaskingStream } from "../protocols/responses/stream-transformer";
+import { ProviderError } from "../providers/errors";
+import type { SecretsProcessResult } from "../secrets/request";
+import {
+ createLogData,
+ errorFormats,
+ handleProviderError,
+ setBlockedHeaders,
+ setResponseHeaders,
+ setStreamingHeaders,
+ toPIIHeaderData,
+ toPIILogData,
+ toSecretsHeaderData,
+ toSecretsLogData,
+} from "./utils";
+
+const MAX_NESTING_DEPTH = 128;
+
+const OpenAIResponsesRequestSchema = z
+ .object({
+ model: z.string().optional(),
+ instructions: z.unknown().optional(),
+ input: z.unknown().optional(),
+ stream: z.boolean().optional(),
+ })
+ .passthrough()
+ .superRefine((value, ctx) => {
+ if (exceedsNestingDepth(value)) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: `Request nesting exceeds the maximum depth of ${MAX_NESTING_DEPTH}`,
+ });
+ }
+ });
+
+const FORWARDED_HEADERS = new Set([
+ "accept",
+ "anthropic-beta",
+ "api-key",
+ "authorization",
+ "http-referer",
+ "idempotency-key",
+ "openai-beta",
+ "openai-organization",
+ "openai-project",
+ "traceparent",
+ "tracestate",
+ "user-agent",
+ "x-anthropic-beta",
+ "x-api-key",
+ "x-client-request-id",
+ "x-request-id",
+ "x-title",
+]);
+
+const FORWARDED_HEADER_PREFIXES = ["x-openai-", "x-openrouter-", "x-stainless-"];
+
+export const openaiResponsesRoutes = new Hono();
+
+function registerResponsesRoute(path: "/responses" | "/responses/") {
+ openaiResponsesRoutes.post(
+ path,
+ zValidator("json", OpenAIResponsesRequestSchema, (result, c) => {
+ if (!result.success) {
+ return c.json(
+ errorFormats.openai.error(
+ `Invalid request body: ${result.error.message}`,
+ "invalid_request_error",
+ ),
+ 400,
+ );
+ }
+ }),
+ (c) => handleResponsesRequest(c, c.req.valid("json") as ResponsesRequest),
+ );
+}
+
+registerResponsesRoute("/responses");
+registerResponsesRoute("/responses/");
+
+async function handleResponsesRequest(c: Context, request: ResponsesRequest) {
+ const startTime = Date.now();
+ const config = getConfig();
+
+ let privacy: PrivacyPipelineResult<ResponsesRequest>;
+ try {
+ privacy = await processPrivacyPipeline(request, config, responsesExtractor);
+ } catch (error) {
+ if (error instanceof PrivacyPipelineDetectionError) {
+ console.error("PII detection error:", error.cause ?? error);
+ return respondDetectionError(c, error.request as ResponsesRequest, startTime);
+ }
+ throw error;
+ }
+
+ const { secretsResult, piiResult } = privacy;
+ if (secretsResult.blocked) {
+ return respondBlocked(c, request, secretsResult, startTime);
+ }
+ if (!piiResult) {
+ throw new Error("PII detection result missing from privacy pipeline");
+ }
+
+ const shouldBlockRouteMode =
+ config.mode === "route" &&
+ (piiResult.hasPII ||
+ (secretsResult.detection?.detected && config.secrets_detection.action === "route_local"));
+ if (shouldBlockRouteMode) {
+ return respondRouteModeBlocked(c, request, piiResult, secretsResult, startTime);
+ }
+
+ const piiContext = contextWithMappings(privacy.piiMaskingContext);
+ const secretsContext = contextWithMappings(secretsResult.maskingContext);
+ const hasSensitiveData = Boolean(piiContext || secretsContext);
+
+ if (hasSensitiveData && statefulOption(request)) {
+ return respondStatefulRequestBlocked(
+ c,
+ request,
+ privacy.request,
+ piiResult,
+ secretsResult,
+ startTime,
+ );
+ }
+
+ let upstreamRequest = remaskKnownValues(privacy.request, secretsContext, piiContext);
+ if (hasSensitiveData && request.store === undefined) {
+ upstreamRequest = { ...upstreamRequest, store: false };
+ }
+
+ return sendToOpenAI(c, request, upstreamRequest, {
+ piiResult,
+ piiContext,
+ secretsResult,
+ secretsContext,
+ startTime,
+ });
+}
+
+interface SendOptions {
+ piiResult: PIIDetectResult;
+ piiContext?: PlaceholderContext;
+ secretsResult: SecretsProcessResult<ResponsesRequest>;
+ secretsContext?: PlaceholderContext;
+ startTime: number;
+}
+
+async function sendToOpenAI(
+ c: Context,
+ originalRequest: ResponsesRequest,
+ request: ResponsesRequest,
+ options: SendOptions,
+) {
+ const config = getConfig();
+ const { piiResult, piiContext, secretsResult, secretsContext, startTime } = options;
+ const maskedContent =
+ piiResult.hasPII || secretsResult.masked
+ ? formatMaskedRequestForLog(request, responsesExtractor, config)
+ : undefined;
+
+ setResponseHeaders(
+ c,
+ config.mode,
+ "openai",
+ toPIIHeaderData(piiResult),
+ toSecretsHeaderData(secretsResult),
+ );
+
+ try {
+ const response = await callOpenAIResponses(
+ request,
+ config.providers.openai,
+ c.req.header(),
+ new URL(c.req.url).search,
+ c.req.raw.signal,
+ );
+ const contentType = response.headers.get("content-type") || "";
+
+ if (contentType.includes("text/event-stream") || request.stream === true) {
+ if (!response.body) throw new Error("No response body for streaming request");
+ logSuccess(c, originalRequest, piiResult, secretsResult, startTime, maskedContent);
+ setStreamingHeaders(c);
+ return c.body(
+ piiContext || secretsContext
+ ? createResponsesUnmaskingStream(
+ response.body,
+ piiContext,
+ config.masking,
+ secretsContext,
+ )
+ : response.body,
+ );
+ }
+
+ const body = (await response.json()) as ResponsesResponse;
+ logSuccess(c, originalRequest, piiResult, secretsResult, startTime, maskedContent);
+ return respondJson(c, body, piiContext, secretsContext, config.masking);
+ } catch (error) {
+ return handleProviderError(
+ c,
+ error,
+ {
+ provider: "openai",
+ model: originalRequest.model || "unknown",
+ startTime,
+ pii: toPIILogData(piiResult),
+ secrets: toSecretsLogData(secretsResult),
+ maskedContent,
+ userAgent: c.req.header("User-Agent") || null,
+ },
+ (message) => errorFormats.openai.error(message, "server_error", "upstream_error"),
+ );
+ }
+}
+
+async function callOpenAIResponses(
+ request: ResponsesRequest,
+ provider: OpenAIProviderConfig,
+ clientHeaders: Record<string, string>,
+ query: string,
+ requestSignal?: AbortSignal,
+): Promise<Response> {
+ const timeoutMs = getConfig().server.request_timeout * 1000;
+ const signals = [
+ requestSignal,
+ timeoutMs > 0 ? AbortSignal.timeout(timeoutMs) : undefined,
+ ].filter((signal): signal is AbortSignal => Boolean(signal));
+ const signal = signals.length > 1 ? AbortSignal.any(signals) : signals[0];
+ const response = await fetch(`${provider.base_url.replace(/\/$/, "")}/responses${query}`, {
+ method: "POST",
+ headers: buildUpstreamHeaders(clientHeaders, provider),
+ body: JSON.stringify(request),
+ signal,
+ });
+
+ if (!response.ok) {
+ throw new ProviderError(response.status, response.statusText, await response.text());
+ }
+ return response;
+}
+
+function buildUpstreamHeaders(
+ clientHeaders: Record<string, string>,
+ provider: OpenAIProviderConfig,
+): Record<string, string> {
+ const headers: Record<string, string> = { "Content-Type": "application/json" };
+ let hasClientAuth = false;
+
+ for (const [name, value] of Object.entries(clientHeaders)) {
+ const lower = name.toLowerCase();
+ if (
+ !FORWARDED_HEADERS.has(lower) &&
+ !FORWARDED_HEADER_PREFIXES.some((prefix) => lower.startsWith(prefix))
+ ) {
+ continue;
+ }
+ headers[name] = value;
+ if (lower === "authorization" || lower === "api-key" || lower === "x-api-key") {
+ hasClientAuth = true;
+ }
+ }
+
+ if (!hasClientAuth && provider.api_key) {
+ headers.Authorization = `Bearer ${provider.api_key}`;
+ }
+ return headers;
+}
+
+function remaskKnownValues(
+ request: ResponsesRequest,
+ ...contexts: Array<PlaceholderContext | undefined>
+): ResponsesRequest {
+ const replacements = new Map<string, string>();
+ for (const context of contexts) {
+ if (!context) continue;
+ for (const [placeholder, original] of Object.entries(context.mapping)) {
+ if (original && !replacements.has(original)) replacements.set(original, placeholder);
+ }
+ }
+ if (replacements.size === 0) return request;
+
+ const ordered = [...replacements].sort(([a], [b]) => b.length - a.length);
+ const changed = responsesExtractor.extractTexts(request).flatMap((span) => {
+ let maskedText = span.text;
+ for (const [original, placeholder] of ordered) {
+ maskedText = maskedText.split(original).join(placeholder);
+ }
+ return maskedText === span.text ? [] : [{ ...span, maskedText }];
+ });
+
+ return changed.length > 0 ? responsesExtractor.applyMasked(request, changed) : request;
+}
+
+function statefulOption(request: ResponsesRequest): string | undefined {
+ if (request.background === true) return "background";
+ if (request.conversation !== undefined && request.conversation !== null) return "conversation";
+ if (request.previous_response_id !== undefined && request.previous_response_id !== null) {
+ return "previous_response_id";
+ }
+ if (
+ request.context_management !== undefined &&
+ request.context_management !== null &&
+ (!Array.isArray(request.context_management) || request.context_management.length > 0)
+ ) {
+ return "context_management";
+ }
+ if (request.store === true) return "store";
+ return undefined;
+}
+
+function contextWithMappings(
+ context: PlaceholderContext | undefined,
+): PlaceholderContext | undefined {
+ return context && Object.keys(context.mapping).length > 0 ? context : undefined;
+}
+
+function exceedsNestingDepth(value: unknown): boolean {
+ const stack: Array<{ value: unknown; depth: number }> = [{ value, depth: 0 }];
+ while (stack.length > 0) {
+ const current = stack.pop()!;
+ if (current.depth > MAX_NESTING_DEPTH) return true;
+ if (!current.value || typeof current.value !== "object") continue;
+ for (const child of Object.values(current.value)) {
+ stack.push({ value: child, depth: current.depth + 1 });
+ }
+ }
+ return false;
+}
+
+function respondBlocked(
+ c: Context,
+ request: ResponsesRequest,
+ secretsResult: SecretsProcessResult<ResponsesRequest>,
+ startTime: number,
+) {
+ const types = secretsResult.blockedTypes ?? [];
+ setBlockedHeaders(c, types);
+ logRequest(
+ createLogData({
+ provider: "openai",
+ model: request.model || "unknown",
+ startTime,
+ secrets: { detected: true, types, masked: false },
+ statusCode: 400,
+ errorMessage: secretsResult.blockedReason,
+ }),
+ c.req.header("User-Agent") || null,
+ );
+ return c.json(
+ errorFormats.openai.error(
+ `Request blocked: detected secret material (${types.join(",")}). Remove secrets and retry.`,
+ "invalid_request_error",
+ "secrets_detected",
+ ),
+ 400,
+ );
+}
+
+function respondDetectionError(c: Context, request: ResponsesRequest, startTime: number) {
+ logRequest(
+ createLogData({
+ provider: "openai",
+ model: request.model || "unknown",
+ startTime,
+ statusCode: 503,
+ errorMessage: "Detection service unavailable",
+ }),
+ c.req.header("User-Agent") || null,
+ );
+ return c.json(
+ errorFormats.openai.error(
+ "Detection service unavailable",
+ "server_error",
+ "service_unavailable",
+ ),
+ 503,
+ );
+}
+
+function respondRouteModeBlocked(
+ c: Context,
+ request: ResponsesRequest,
+ piiResult: PIIDetectResult,
+ secretsResult: SecretsProcessResult<ResponsesRequest>,
+ startTime: number,
+) {
+ const message =
+ "OpenAI Responses cannot route sensitive requests to a local provider. Use mask mode or remove sensitive data.";
+ setResponseHeaders(
+ c,
+ "route",
+ "openai",
+ toPIIHeaderData(piiResult),
+ toSecretsHeaderData(secretsResult),
+ );
+ logRequest(
+ createLogData({
+ provider: "openai",
+ model: request.model || "unknown",
+ startTime,
+ pii: toPIILogData(piiResult),
+ secrets: toSecretsLogData(secretsResult),
+ statusCode: 400,
+ errorMessage: message,
+ }),
+ c.req.header("User-Agent") || null,
+ );
+ return c.json(
+ errorFormats.openai.error(message, "invalid_request_error", "route_mode_not_supported"),
+ 400,
+ );
+}
+
+function respondStatefulRequestBlocked(
+ c: Context,
+ request: ResponsesRequest,
+ maskedRequest: ResponsesRequest,
+ piiResult: PIIDetectResult,
+ secretsResult: SecretsProcessResult<ResponsesRequest>,
+ startTime: number,
+) {
+ const option = statefulOption(request)!;
+ const message = `Responses option '${option}' cannot preserve request-local placeholders. Use a stateless request with store=false.`;
+ setResponseHeaders(
+ c,
+ getConfig().mode,
+ "openai",
+ toPIIHeaderData(piiResult),
+ toSecretsHeaderData(secretsResult),
+ );
+ logRequest(
+ createLogData({
+ provider: "openai",
+ model: request.model || "unknown",
+ startTime,
+ pii: toPIILogData(piiResult),
+ secrets: toSecretsLogData(secretsResult),
+ maskedContent: formatMaskedRequestForLog(maskedRequest, responsesExtractor, getConfig()),
+ statusCode: 400,
+ errorMessage: message,
+ }),
+ c.req.header("User-Agent") || null,
+ );
+ return c.json(
+ errorFormats.openai.error(message, "invalid_request_error", "stateful_responses_not_supported"),
+ 400,
+ );
+}
+
+function logSuccess(
+ c: Context,
+ request: ResponsesRequest,
+ piiResult: PIIDetectResult,
+ secretsResult: SecretsProcessResult<ResponsesRequest>,
+ startTime: number,
+ maskedContent?: string,
+) {
+ logRequest(
+ createLogData({
+ provider: "openai",
+ model: request.model || "unknown",
+ startTime,
+ pii: toPIILogData(piiResult),
+ secrets: toSecretsLogData(secretsResult),
+ maskedContent,
+ statusCode: 200,
+ }),
+ c.req.header("User-Agent") || null,
+ );
+}
+
+function respondJson(
+ c: Context,
+ response: ResponsesResponse,
+ piiContext?: PlaceholderContext,
+ secretsContext?: PlaceholderContext,
+ maskingConfig: MaskingConfig = getConfig().masking,
+) {
+ return c.json(
+ restoreResponse(response, responsesExtractor, maskingConfig, {
+ piiContext,
+ secretsContext,
+ }),
+ );
+}
type OpenAIResponse,
} from "../providers/openai/types";
import type { SecretsProcessResult } from "../secrets/request";
+import { openaiResponsesRoutes } from "./openai-responses";
import {
createLogData,
errorFormats,
},
);
+openaiRoutes.route("/v1", openaiResponsesRoutes);
+
openaiRoutes.all("/*", (c) => {
+ const normalizedPath = normalizeOpenAIPath(c.req.path);
+ if (!normalizedPath || normalizedPath === "/v1/responses") {
+ return c.json(
+ errorFormats.openai.error(
+ "Unsupported protected OpenAI endpoint",
+ "invalid_request_error",
+ "not_found",
+ ),
+ 404,
+ );
+ }
+
const config = getConfig();
const { baseUrl } = getOpenAIInfo(config.providers.openai);
const path = c.req.path.replace(/^\/openai\/v1/, "");
});
});
+function normalizeOpenAIPath(path: string): string | undefined {
+ let decoded = path;
+ let stable = false;
+
+ try {
+ for (let pass = 0; pass < 8; pass++) {
+ const next = decodeURIComponent(decoded);
+ if (next === decoded) {
+ stable = true;
+ break;
+ }
+ decoded = next;
+ }
+ } catch {
+ return undefined;
+ }
+
+ if (!stable) return undefined;
+
+ const segments: string[] = [];
+ for (const segment of decoded.replaceAll("\\", "/").split("/")) {
+ const normalized = segment.split(";", 1)[0];
+ if (!normalized || normalized === ".") continue;
+ if (normalized === "..") {
+ segments.pop();
+ continue;
+ }
+ segments.push(normalized);
+ }
+
+ const normalized = `/${segments.join("/")}`;
+ return normalized === "/openai"
+ ? "/"
+ : normalized.startsWith("/openai/")
+ ? normalized.slice("/openai".length)
+ : normalized;
+}
+
// --- Types ---
interface OpenAIOptions {