feat(pii): apply PII middleware to the Open Responses API (#12708)

* feat(pii): apply PII middleware to the Open Responses API

The per-model PII filter ran on /v1/chat/completions, /v1/completions,
/v1/messages and the Ollama routes, but not on POST /v1/responses. A
model whose policy blocks or masks a secret on the chat route served the
same content unfiltered when it was sent through the Responses API.

Add a piiadapter.OpenResponses adapter and wire pii.RequestMiddleware
into the POST /v1/responses and /responses chain, after the model config
is resolved and merged and before the handler converts input items into
prompt messages. The adapter covers instructions, input as a string, and
in an input item array the text of message and reasoning items (string
content or input_text/output_text parts, including typeless
{role, content} items), function_call arguments and function_call_output
output; structured tool output is scanned as the JSON the handler
forwards. Image, file, audio and video parts are left untouched.

The Responses WebSocket mode (GET /v1/responses) is not HTTP middleware
and cannot filter, so a response.create for a model with PII filtering
enabled is now refused with pii_unsupported_transport instead of being
served unfiltered. The docs list the new route and this limit.

Tests: adapter specs for every covered field, each with a clean-text
counter-check; a route spec driving the real middleware chain with an
in-process pattern detector (block on string input, content part,
instructions and tool output; mask; clean input passes unchanged); and
WebSocket specs proving the refusal and that models without a PII
policy are still served.

Assisted-by: Claude Code:claude-opus-5-5
Signed-off-by: Stefan Walcz <stefan.walcz@walcz.de>

* fix(pii): scan complete Responses tool output arrays

The Responses handler forwards non-string tool output as JSON. Scan
that full encoding so string arrays and extra fields cannot bypass PII
filtering. Cover array scans, redaction, clean values and route blocking.

Document that stored history is resolved after request filtering.

Assisted-by: Codex:gpt-6
Signed-off-by: Stefan Walcz <stefan.walcz@walcz.de>

* feat(pii): report unscanned media on the Open Responses route

Extend block_unscanned_media to /v1/responses: input_image, input_file
and input_audio parts inside message items, and the same types as bare
input items, are reported as unscanned content. Tool output stays
allowed, as it is scanned as JSON text.

Assisted-by: Claude Code:claude-opus-5-5
Signed-off-by: Stefan Walcz <stefan.walcz@walcz.de>

---------

Signed-off-by: Stefan Walcz <stefan.walcz@walcz.de>
Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
This commit is contained in:
Stefan Walczandlocalai-org-maint-bot authored and GitHub committed 2026-10-19 23:39:56 +02:00
1 parent e97bd0be76
commit d768f1f973
8 files changed
+710 -21

No files matched your search

+24 -7
View File
@@ -53,11 +53,27 @@ backend default.
Filtering runs on every text-accepting endpoint that has an adapter wired:
`/v1/chat/completions` and `/v1/messages` (chat), `/v1/completions`,
`/v1/embeddings`, `/v1/edits`, and the Ollama `/api/chat`, `/api/generate`
and `/api/embed` endpoints, plus the [MITM proxy]({{< relref "mitm-proxy.md" >}})
request body. Image, audio (TTS/STT), video, rerank, and the realtime
WebSocket are not filtered yet (different prompt-PII semantics; realtime is
not HTTP middleware).
`/v1/embeddings`, `/v1/edits`, the Open Responses `POST /v1/responses`
(`instructions`, string `input`, and in input items the message and
reasoning text, `function_call` arguments and `function_call_output` output),
and the Ollama `/api/chat`, `/api/generate` and `/api/embed` endpoints, plus
the [MITM proxy]({{< relref "mitm-proxy.md" >}}) request body. Image, audio
(TTS/STT), video, rerank, and the realtime WebSocket are not filtered yet
(different prompt-PII semantics; realtime is not HTTP middleware).
The Responses WebSocket mode (`GET /v1/responses`) is not HTTP middleware
either, so it cannot filter: a `response.create` for a model with PII
filtering enabled is refused with the error type `pii_unsupported_transport`
instead of being served unfiltered. Use `POST /v1/responses` for those models.
Requests whose `model` names an agent are handed to the agent pool before
model resolution and are not filtered by the per-model policy.
Stored history loaded through `previous_response_id` or `item_reference`
is resolved after filtering and is not scanned against the current model's
policy. That history can contain unfiltered text from another model or
model-generated output. Tool output supplied directly in the request is
scanned as text, or as its complete JSON encoding for non-string values,
including arrays.
A request's messages are scanned **as one document** (joined in order), so
the NER detector keeps conversational context: whether `4421` is a PIN or
@@ -277,8 +293,9 @@ they reach the backend unchecked. With the option set, a request that carries
any such content is rejected with HTTP 400 (`error.type=pii_blocked`, audit
event pattern `__unscanned_media__`) before a detector runs. Use it for egress
policies where "cannot be inspected" must mean "not sent", e.g. on
`cloud-proxy` models. It applies to the OpenAI chat, Anthropic messages and
Ollama chat routes, and only while PII is enabled for the model.
`cloud-proxy` models. It applies to the OpenAI chat, Open Responses,
Anthropic messages and Ollama chat routes, and only while PII is enabled for
the model.
Multiple detectors **union** their detections; overlapping spans resolve to
the strongest action (`block` > `mask` > `allow`). A configured detector