From: Stefan Gasser Date: Sun, 12 Jul 2026 08:48:16 +0000 (+0200) Subject: Protect OpenAI Responses requests (#142) X-Git-Tag: v0.8.0~1 X-Git-Url: http://git.99rst.org/?a=commitdiff_plain;h=bb2bf2550d75993cb4ef0d102abd7bd10d45eefc;p=sgasser-llm-shield.git Protect OpenAI Responses requests (#142) --- diff --git a/README.md b/README.md index ecdad66..5e5af2b 100644 --- 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 diff --git a/docs/api-reference/openai.mdx b/docs/api-reference/openai.mdx index caaa8ee..7c3000a 100644 --- a/docs/api-reference/openai.mdx +++ b/docs/api-reference/openai.mdx @@ -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 ``` -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. -## Request +## Chat Completions request ```bash curl http://localhost:3000/openai/v1/chat/completions \ @@ -107,6 +108,22 @@ for await (const chunk of stream) { +## 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: diff --git a/docs/use-cases/apps.mdx b/docs/use-cases/apps.mdx index f5dac83..680f6fa 100644 --- a/docs/use-cases/apps.mdx +++ b/docs/use-cases/apps.mdx @@ -17,6 +17,25 @@ OPENAI_API_KEY=your_openai_api_key In Docker Compose, use the service name instead of `localhost`, such as `http://pasteguard:3000/openai/v1`. +### 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: diff --git a/src/masking/extractors/codex.test.ts b/src/masking/extractors/responses.test.ts similarity index 93% rename from src/masking/extractors/codex.test.ts rename to src/masking/extractors/responses.test.ts index 7918ad4..fa42c2f 100644 --- a/src/masking/extractors/codex.test.ts +++ b/src/masking/extractors/responses.test.ts @@ -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, diff --git a/src/masking/extractors/codex.ts b/src/masking/extractors/responses.ts similarity index 88% rename from src/masking/extractors/codex.ts rename to src/masking/extractors/responses.ts index be9aad2..f9df568 100644 --- a/src/masking/extractors/codex.ts +++ b/src/masking/extractors/responses.ts @@ -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; +export type ResponsesResponse = Record; const TEXT_KEYS = new Set([ "arguments", @@ -110,8 +110,8 @@ function pathFromString(path: string): Array { return result; } -export const codexExtractor: RequestExtractor = { - extractTexts(request: CodexResponsesRequest): TextSpan[] { +export const responsesExtractor: RequestExtractor = { + 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 setAtPath(current, pathFromString(span.path), span.maskedText), request, @@ -129,10 +129,10 @@ export const codexExtractor: RequestExtractor string, - ): CodexResponsesResponse { + ): ResponsesResponse { let result = response; for (const item of collectText(response)) { result = setAtPath(result, item.path, restorePlaceholders(item.value, context, formatValue)); diff --git a/src/masking/restorer.test.ts b/src/masking/restorer.test.ts index 3ed2b77..e07d5d2 100644 --- a/src/masking/restorer.test.ts +++ b/src/masking/restorer.test.ts @@ -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" }), }); diff --git a/src/providers/codex/stream-transformer.test.ts b/src/protocols/responses/stream-transformer.test.ts similarity index 87% rename from src/providers/codex/stream-transformer.test.ts rename to src/protocols/responses/stream-transformer.test.ts index 4c02370..150a931 100644 --- a/src/providers/codex/stream-transformer.test.ts +++ b/src/protocols/responses/stream-transformer.test.ts @@ -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"'); diff --git a/src/providers/codex/stream-transformer.ts b/src/protocols/responses/stream-transformer.ts similarity index 88% rename from src/providers/codex/stream-transformer.ts rename to src/protocols/responses/stream-transformer.ts index 5aa93be..6c3d301 100644 --- a/src/providers/codex/stream-transformer.ts +++ b/src/protocols/responses/stream-transformer.ts @@ -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, 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, diff --git a/src/routes/codex.ts b/src/routes/codex.ts index b7bb4dc..445b4dd 100644 --- a/src/routes/codex.ts +++ b/src/routes/codex.ts @@ -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; 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 { 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 index 0000000..ee2530d --- /dev/null +++ b/src/routes/openai-responses.test.ts @@ -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>(() => 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 | 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 = { 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 index 0000000..a6a1852 --- /dev/null +++ b/src/routes/openai-responses.ts @@ -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; + 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; + 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, + query: string, + requestSignal?: AbortSignal, +): Promise { + 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, + provider: OpenAIProviderConfig, +): Record { + const headers: Record = { "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 +): ResponsesRequest { + const replacements = new Map(); + 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, + 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, + 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, + 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, + 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, + }), + ); +} diff --git a/src/routes/openai.ts b/src/routes/openai.ts index becfb03..43a3efa 100644 --- a/src/routes/openai.ts +++ b/src/routes/openai.ts @@ -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 {