]> git.99rst.org Git - sgasser-llm-shield.git/commitdiff
Protect OpenAI Responses requests (#142)
authorStefan Gasser <redacted>
Sun, 12 Jul 2026 08:48:16 +0000 (10:48 +0200)
committerGitHub <redacted>
Sun, 12 Jul 2026 08:48:16 +0000 (10:48 +0200)
12 files changed:
README.md
docs/api-reference/openai.mdx
docs/use-cases/apps.mdx
src/masking/extractors/responses.test.ts [moved from src/masking/extractors/codex.test.ts with 93% similarity]
src/masking/extractors/responses.ts [moved from src/masking/extractors/codex.ts with 88% similarity]
src/masking/restorer.test.ts
src/protocols/responses/stream-transformer.test.ts [moved from src/providers/codex/stream-transformer.test.ts with 87% similarity]
src/protocols/responses/stream-transformer.ts [moved from src/providers/codex/stream-transformer.ts with 88% similarity]
src/routes/codex.ts
src/routes/openai-responses.test.ts [new file with mode: 0644]
src/routes/openai-responses.ts [new file with mode: 0644]
src/routes/openai.ts

index ecdad66fb4fab3fc6618c39dcc6c9f7b12235026..5e5af2b76a8f8845aef7f4b2a5164451e80063a1 100644 (file)
--- a/README.md
+++ b/README.md
@@ -100,6 +100,8 @@ from openai import OpenAI
 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
index caaa8ee79648890a601901663bbdf3daa518ade3..7c3000a2833c113fc6ce25ca4bc0ee1f3c6c4869 100644 (file)
@@ -1,19 +1,20 @@
 ---
 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 \
@@ -107,6 +108,22 @@ for await (const chunk of stream) {
 
 </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:
index f5dac83936fca235b958f78e8fab86d04ec00289..680f6fa091cb2e69ddf04dc6261be403ee9670f5 100644 (file)
@@ -17,6 +17,25 @@ OPENAI_API_KEY=your_openai_api_key
 
 <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:
similarity index 93%
rename from src/masking/extractors/codex.test.ts
rename to src/masking/extractors/responses.test.ts
index 7918ad422c4306e206890ab43b0bedb1366dd55d..fa42c2fe12b561992301788bb08a044b7a95537b 100644 (file)
@@ -1,9 +1,9 @@
 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: [
@@ -51,7 +51,7 @@ describe("Codex Text Extractor", () => {
     };
 
     expect(
-      codexExtractor.extractTexts(request).map((span) => ({
+      responsesExtractor.extractTexts(request).map((span) => ({
         path: span.path,
         role: span.role,
         text: span.text,
similarity index 88%
rename from src/masking/extractors/codex.ts
rename to src/masking/extractors/responses.ts
index be9aad29145427928eecb0b78fd2ffc03b82cc80..f9df568f3cbb02d75fd900071b3b949d71bf607b 100644 (file)
@@ -1,15 +1,15 @@
 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",
@@ -110,8 +110,8 @@ function pathFromString(path: string): Array<string | number> {
   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),
@@ -121,7 +121,7 @@ export const codexExtractor: RequestExtractor<CodexResponsesRequest, CodexRespon
     }));
   },
 
-  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,
@@ -129,10 +129,10 @@ export const codexExtractor: RequestExtractor<CodexResponsesRequest, CodexRespon
   },
 
   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));
index 3ed2b776d0b4dc370eea5b31dc7bd6de4369aaf3..e07d5d230cb1b1a04d80ea7f8fdb82a41697b74d 100644 (file)
@@ -4,8 +4,8 @@ import type { AnthropicResponse } from "../providers/anthropic/types";
 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";
 
@@ -127,11 +127,11 @@ describe("restoreResponse applies markers through each provider extractor", () =
   });
 
   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" }),
     });
 
similarity index 87%
rename from src/providers/codex/stream-transformer.test.ts
rename to src/protocols/responses/stream-transformer.test.ts
index 4c0237065b9e26ec4289e0355eda37c4eab4dbf2..150a931a85265cca7fc5e387b84787acd80fc533 100644 (file)
@@ -1,7 +1,7 @@
 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,
@@ -50,13 +50,13 @@ function codexDelta(text: string): string {
   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");
@@ -67,7 +67,7 @@ describe("createCodexUnmaskingStream", () => {
     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");
@@ -80,7 +80,7 @@ describe("createCodexUnmaskingStream", () => {
     const source = createSSEStream([codexDelta("[[PERSON_1]] used [[API_KEY_SK_1]]")]);
 
     const result = await consumeStream(
-      createCodexUnmaskingStream(
+      createResponsesUnmaskingStream(
         source,
         piiContext,
         { ...defaultConfig, show_markers: true },
@@ -95,7 +95,7 @@ describe("createCodexUnmaskingStream", () => {
     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");
@@ -107,7 +107,7 @@ describe("createCodexUnmaskingStream", () => {
     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"');
similarity index 88%
rename from src/providers/codex/stream-transformer.ts
rename to src/protocols/responses/stream-transformer.ts
index 5aa93be6de57b69086e42929e767f443505fefc3..6c3d301f0142fccea0afd16f2a1dfaab59afd90a 100644 (file)
@@ -1,9 +1,9 @@
 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,
@@ -19,14 +19,14 @@ export function createCodexUnmaskingStream(
   });
 
   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,
index b7bb4dcaf15b1cb9319575e1964aacb3abd9e625..445b4dd6b7547a7ef5fb3d9818f298f402f11b92 100644 (file)
@@ -8,10 +8,10 @@ import { formatMaskedRequestForLog } from "../logging/log-content";
 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 {
@@ -19,7 +19,7 @@ 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 {
@@ -66,7 +66,7 @@ codexRoutes.post(
 
     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);
@@ -167,7 +167,7 @@ function getForwardHeaders(c: Context): Record<string, string> {
 
 function formatCodexForLog(request: CodexResponsesRequest): string | undefined {
   const config = getConfig();
-  return formatMaskedRequestForLog(request, codexExtractor, config);
+  return formatMaskedRequestForLog(request, responsesExtractor, config);
 }
 
 function respondBlocked(
@@ -353,7 +353,9 @@ function respondStreaming(
   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);
@@ -366,7 +368,7 @@ function respondJson(
   secretsContext?: PlaceholderContext,
   maskingConfig = getConfig().masking,
 ) {
-  const result = restoreResponse(response, codexExtractor, maskingConfig, {
+  const result = restoreResponse(response, responsesExtractor, maskingConfig, {
     piiContext,
     secretsContext,
   });
diff --git a/src/routes/openai-responses.test.ts b/src/routes/openai-responses.test.ts
new file mode 100644 (file)
index 0000000..ee2530d
--- /dev/null
@@ -0,0 +1,329 @@
+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();
+  });
+});
diff --git a/src/routes/openai-responses.ts b/src/routes/openai-responses.ts
new file mode 100644 (file)
index 0000000..a6a1852
--- /dev/null
@@ -0,0 +1,506 @@
+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,
+    }),
+  );
+}
index becfb030362d69c245f938fd13b2f366b8817d9f..43a3efa44cf2497196e8a5bf5279c25eba4aaae3 100644 (file)
@@ -23,6 +23,7 @@ import {
   type OpenAIResponse,
 } from "../providers/openai/types";
 import type { SecretsProcessResult } from "../secrets/request";
+import { openaiResponsesRoutes } from "./openai-responses";
 import {
   createLogData,
   errorFormats,
@@ -112,7 +113,21 @@ openaiRoutes.post(
   },
 );
 
+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/, "");
@@ -128,6 +143,44 @@ openaiRoutes.all("/*", (c) => {
   });
 });
 
+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 {
git clone https://git.99rst.org/PROJECT