From b33e76ae5a6c3af8108b992abd281d15af6cb4ff Mon Sep 17 00:00:00 2001 From: Tim Date: Sat, 25 Apr 2026 23:16:55 -0500 Subject: [PATCH] feat: add ai-peyeeye plugin for PII redaction & rehydration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an `ai-peyeeye` AI plugin that redacts PII from prompts before they reach the upstream LLM and rehydrates the model's response so the client sees the original values. The plugin calls the peyeeye.ai HTTP API (`/v1/redact`, `/v1/rehydrate`, `DELETE /v1/sessions/`) and is designed to sit alongside `ai-proxy` / `ai-proxy-multi` on the same route, at priority 1074 (ahead of `ai-proxy`'s 1040). Behavior invariants: - Length-guard: if `/v1/redact` returns a different number of texts than were sent, or returns an unexpected response shape, the request is failed with HTTP 500. Unredacted text is never forwarded upstream. - Auth required: missing `api_key` (in config or `PEYEEYE_API_KEY` env var) fails schema validation. - Best-effort rehydrate: if `/v1/rehydrate` fails the redacted output is preserved rather than risking PII leakage. - Best-effort cleanup: stateful sessions are `DELETE`'d after rehydrate; failures are logged only. Two session modes are supported: `stateful` (default; peyeeye holds the token-to-value map under a `ses_…` id) and `stateless` (peyeeye returns a sealed `skey_…` blob and retains nothing). Includes English and Chinese documentation, plugin registration in `apisix/cli/config.lua`, `conf/config.yaml.example`, the docs sidebars, and the admin plugin list (`t/admin/plugins.t`). Plugin tests under `t/plugin/ai-peyeeye.t` mock the peyeeye HTTP API and a fake LLM upstream so they run with no external dependencies, exercising: schema validation (3 cases), the stateful redact+rehydrate end-to-end flow, the stateless mode, the length-guard branch, the unexpected-response-shape branch, and the empty-body short-circuit. --- apisix/cli/config.lua | 1 + apisix/plugins/ai-peyeeye.lua | 546 +++++++++++++++++++++++++++ conf/config.yaml.example | 1 + docs/en/latest/config.json | 1 + docs/en/latest/plugins/ai-peyeeye.md | 123 ++++++ docs/zh/latest/config.json | 1 + docs/zh/latest/plugins/ai-peyeeye.md | 119 ++++++ t/admin/plugins.t | 1 + t/plugin/ai-peyeeye.t | 445 ++++++++++++++++++++++ 9 files changed, 1238 insertions(+) create mode 100644 apisix/plugins/ai-peyeeye.lua create mode 100644 docs/en/latest/plugins/ai-peyeeye.md create mode 100644 docs/zh/latest/plugins/ai-peyeeye.md create mode 100644 t/plugin/ai-peyeeye.t diff --git a/apisix/cli/config.lua b/apisix/cli/config.lua index 956eef30c267..3ca87a484abe 100644 --- a/apisix/cli/config.lua +++ b/apisix/cli/config.lua @@ -231,6 +231,7 @@ local _M = { "ai-prompt-template", "ai-prompt-decorator", "ai-prompt-guard", + "ai-peyeeye", "ai-rag", "ai-rate-limiting", "ai-proxy-multi", diff --git a/apisix/plugins/ai-peyeeye.lua b/apisix/plugins/ai-peyeeye.lua new file mode 100644 index 000000000000..e0ce6319627e --- /dev/null +++ b/apisix/plugins/ai-peyeeye.lua @@ -0,0 +1,546 @@ +-- +-- Licensed to the Apache Software Foundation (ASF) under one or more +-- contributor license agreements. See the NOTICE file distributed with +-- this work for additional information regarding copyright ownership. +-- The ASF licenses this file to You under the Apache License, Version 2.0 +-- (the "License"); you may not use this file except in compliance with +-- the License. You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. +-- + +--- ai-peyeeye: PII redaction & rehydration via the peyeeye.ai API. +-- +-- On request the plugin extracts every text-bearing chunk from the +-- request body, sends them in a single batch to peyeeye's /v1/redact, +-- swaps the redacted text back into the request before it reaches the +-- LLM, and records the session id on the request context. +-- +-- On response the plugin reads the model's text, sends it to /v1/rehydrate +-- so placeholders are swapped back to the originals, replaces the response +-- payload, and best-effort DELETEs the session. +-- +-- Behavioral invariants (no silent PII passthrough): +-- +-- * If /v1/redact returns a different number of texts than were sent, +-- or returns an unexpected response shape, access() fails closed +-- (HTTP 500) — the unredacted text is never forwarded upstream. +-- * If the api_key is missing the plugin refuses to load. +-- * Rehydrate failures fall back to the model's redacted output rather +-- than leaking PII. +-- +-- This plugin is designed to be paired with ai-proxy / ai-proxy-multi +-- (the same way ai-aliyun-content-moderation is); it relies on the AI +-- proxy flow to invoke lua_body_filter for response rehydration. + +local core = require("apisix.core") +local protocols = require("apisix.plugins.ai-protocols") +local http = require("resty.http") +local url = require("socket.url") + +local ngx = ngx +local ngx_ok = ngx.OK +local ipairs = ipairs +local pairs = pairs +local type = type +local tostring = tostring +local table = table +local string = string +local os = os + +local plugin_name = "ai-peyeeye" + +local DEFAULT_API_BASE = "https://api.peyeeye.ai" + + +local schema = { + type = "object", + properties = { + api_key = { + type = "string", + minLength = 1, + description = "peyeeye API key (Bearer). Falls back to env PEYEEYE_API_KEY.", + }, + api_base = { + type = "string", + minLength = 1, + default = DEFAULT_API_BASE, + description = "peyeeye API base URL. Defaults to https://api.peyeeye.ai.", + }, + locale = { + type = "string", + default = "auto", + description = "BCP-47 locale hint passed to /v1/redact. Defaults to 'auto'.", + }, + entities = { + type = "array", + items = { type = "string", minLength = 1 }, + description = "Optional whitelist of peyeeye entity ids to detect. " .. + "When omitted the server uses its default set.", + }, + session_mode = { + type = "string", + enum = { "stateful", "stateless" }, + default = "stateful", + description = "stateful: peyeeye retains the token->value map under a ses_… id. " .. + "stateless: peyeeye returns a sealed skey_… blob and retains nothing.", + }, + timeout = { + type = "integer", + minimum = 1, + default = 15000, + description = "HTTP timeout in milliseconds for calls to the peyeeye API.", + }, + keepalive = { type = "boolean", default = true }, + keepalive_pool = { type = "integer", minimum = 1, default = 30 }, + keepalive_timeout = { type = "integer", minimum = 1000, default = 60000 }, + ssl_verify = { type = "boolean", default = true }, + }, + encrypt_fields = { "api_key" }, +} + + +local _M = { + version = 0.1, + -- Higher than ai-proxy (1040) and ai-aliyun-content-moderation (1029) so + -- redaction happens before the request reaches the AI provider. + priority = 1074, + name = plugin_name, + schema = schema, +} + + +function _M.check_schema(conf) + local ok, err = core.schema.check(schema, conf) + if not ok then + return false, err + end + + if not conf.api_key or conf.api_key == "" then + local from_env = os.getenv("PEYEEYE_API_KEY") + if not from_env or from_env == "" then + return false, "ai-peyeeye: api_key is required " .. + "(set in plugin config or via PEYEEYE_API_KEY env var)" + end + end + + return true +end + + +-- ----------------------------------------------------------------- internals + +local function resolve_api_key(conf) + if conf.api_key and conf.api_key ~= "" then + return conf.api_key + end + return os.getenv("PEYEEYE_API_KEY") +end + + +local function resolve_api_base(conf) + local base = conf.api_base + if not base or base == "" then + base = os.getenv("PEYEEYE_API_BASE") or DEFAULT_API_BASE + end + -- strip trailing slash to keep path concatenation predictable. + if string.sub(base, -1) == "/" then + base = string.sub(base, 1, -2) + end + return base +end + + +local function build_headers(conf) + return { + ["Authorization"] = "Bearer " .. resolve_api_key(conf), + ["Content-Type"] = "application/json", + ["Accept"] = "application/json", + ["User-Agent"] = "apisix-ai-peyeeye/0.1", + } +end + + +local function peyeeye_request(conf, method, path, body_tab) + local api_base = resolve_api_base(conf) + local full = api_base .. path + + local parsed = url.parse(full) + if not parsed or not parsed.host then + return nil, "invalid api_base: " .. tostring(api_base) + end + + local httpc = http.new() + httpc:set_timeout(conf.timeout) + + local connect_opts = { + scheme = parsed.scheme or "https", + host = parsed.host, + port = tonumber(parsed.port) or (parsed.scheme == "http" and 80 or 443), + ssl_verify = conf.ssl_verify, + ssl_server_name = parsed.host, + pool_size = conf.keepalive and conf.keepalive_pool or nil, + } + local ok, err = httpc:connect(connect_opts) + if not ok then + return nil, "failed to connect to peyeeye: " .. err + end + + local req = { + method = method, + path = parsed.path or path, + headers = build_headers(conf), + } + if parsed.query and parsed.query ~= "" then + req.path = req.path .. "?" .. parsed.query + end + if body_tab ~= nil then + local encoded, encode_err = core.json.encode(body_tab) + if not encoded then + return nil, "failed to encode peyeeye request body: " .. tostring(encode_err) + end + req.body = encoded + end + + local res, req_err = httpc:request(req) + if not res then + return nil, "failed to call peyeeye " .. path .. ": " .. tostring(req_err) + end + + local raw, read_err = res:read_body() + if not raw then + return nil, "failed to read peyeeye response body: " .. tostring(read_err) + end + + if conf.keepalive then + local _, ka_err = httpc:set_keepalive(conf.keepalive_timeout, conf.keepalive_pool) + if ka_err then + core.log.warn("peyeeye: keepalive failed: ", ka_err) + end + else + httpc:close() + end + + if res.status == 401 or res.status == 403 then + return nil, "peyeeye " .. path .. " auth failed (status " .. res.status .. ")" + end + if res.status >= 400 then + return nil, "peyeeye " .. path .. " returned status " .. res.status .. + ", body: " .. tostring(raw) + end + + if not raw or raw == "" then + return {} + end + + local decoded, decode_err = core.json.decode(raw) + if decoded == nil then + return nil, "failed to decode peyeeye response: " .. tostring(decode_err) + end + return decoded +end + + +-- Walk every text-bearing chunk in an OpenAI-chat-style messages list and +-- yield (msg_index, "content"|int, text). The integer is an index into the +-- multimodal content array; "content" means the content field is a string. +local function collect_message_texts(messages) + local out = {} + if type(messages) ~= "table" then + return out + end + for i, msg in ipairs(messages) do + if type(msg) == "table" then + local content = msg.content + if type(content) == "string" and content ~= "" then + table.insert(out, { msg_index = i, part = "content", text = content }) + elseif type(content) == "table" then + for j, part in ipairs(content) do + if type(part) == "table" and part.type == "text" + and type(part.text) == "string" and part.text ~= "" then + table.insert(out, { msg_index = i, part = j, text = part.text }) + end + end + end + end + end + return out +end + + +local function set_message_text(messages, slot, value) + local msg = messages[slot.msg_index] + if type(msg) ~= "table" then + return + end + if slot.part == "content" then + msg.content = value + return + end + local parts = msg.content + if type(parts) == "table" and type(slot.part) == "number" then + local part = parts[slot.part] + if type(part) == "table" then + part.text = value + end + end +end + + +-- Some non-chat protocols (openai-responses, embeddings) place the +-- prompt in fields other than messages[]. To stay framework-aligned +-- and avoid silent passthrough we only redact protocols that expose a +-- messages[] array. For everything else we fall back to extract_request_content +-- and refuse the request rather than leaking PII. +local SUPPORTED_FOR_REWRITE = { + ["openai-chat"] = true, + ["anthropic-messages"] = true, +} + + +local function build_redact_body(conf, texts) + local body = { + text = texts, + locale = conf.locale or "auto", + } + if conf.entities and #conf.entities > 0 then + local copy = {} + for i, e in ipairs(conf.entities) do + copy[i] = e + end + body.entities = copy + end + if conf.session_mode == "stateless" then + body.session = "stateless" + end + return body +end + + +local function extract_session(conf, payload) + if type(payload) ~= "table" then + return nil + end + if conf.session_mode == "stateless" then + return payload.rehydration_key + end + return payload.session_id or payload.session +end + + +-- ----------------------------------------------------------------- access + +function _M.access(conf, ctx) + local body, err = core.request.get_body() + if not body or body == "" then + if err then + core.log.warn("ai-peyeeye: failed to read request body: ", err) + end + return + end + + local body_tab, decode_err = core.json.decode(body) + if not body_tab then + core.log.warn("ai-peyeeye: failed to decode request body as JSON: ", decode_err) + return + end + + local proto_name, detect_err = protocols.detect(body_tab, ctx) + if not proto_name then + core.log.info("ai-peyeeye: skipping (no AI protocol matched: ", detect_err or "", ")") + return + end + + if not SUPPORTED_FOR_REWRITE[proto_name] then + return 500, { message = "ai-peyeeye: protocol '" .. proto_name .. + "' is not yet supported for redaction; refusing to forward unredacted text" } + end + + local messages = body_tab.messages + local slots = collect_message_texts(messages) + if #slots == 0 then + return + end + + local texts = {} + for i, slot in ipairs(slots) do + texts[i] = slot.text + end + + local payload, post_err = peyeeye_request(conf, "POST", "/v1/redact", + build_redact_body(conf, texts)) + if not payload then + core.log.error("ai-peyeeye: /v1/redact failed: ", post_err) + return 500, { message = "ai-peyeeye: redact call failed; " .. + "refusing to forward unredacted text" } + end + + local redacted = payload.text + if type(redacted) ~= "table" then + core.log.error("ai-peyeeye: /v1/redact returned unexpected shape (text not array)") + return 500, { message = "ai-peyeeye: redact returned unexpected response shape; " .. + "refusing to forward unredacted text" } + end + if #redacted ~= #slots then + core.log.error("ai-peyeeye: /v1/redact returned ", #redacted, + " texts for ", #slots, " inputs") + return 500, { message = "ai-peyeeye: redact returned mismatched text count; " .. + "refusing to forward unredacted text" } + end + + for i, slot in ipairs(slots) do + local out = redacted[i] + if type(out) ~= "string" then + core.log.error("ai-peyeeye: /v1/redact item ", i, " is not a string") + return 500, { message = "ai-peyeeye: redact returned non-string entry; " .. + "refusing to forward unredacted text" } + end + set_message_text(messages, slot, out) + end + + local new_body, encode_err = core.json.encode(body_tab) + if not new_body then + core.log.error("ai-peyeeye: failed to re-encode request body: ", encode_err) + return 500, { message = "ai-peyeeye: failed to re-encode redacted body" } + end + ngx.req.set_body_data(new_body) + + local session_id = extract_session(conf, payload) + if session_id and session_id ~= "" then + ctx.peyeeye_session_id = session_id + ctx.peyeeye_session_mode = conf.session_mode + ctx.peyeeye_redacted_count = #slots + else + core.log.info("ai-peyeeye: redact returned no session id; rehydration disabled") + end +end + + +-- ----------------------------------------------------------------- response + +local function rehydrate_text(conf, text, session_id) + if not text or text == "" then + return text + end + local payload, err = peyeeye_request(conf, "POST", "/v1/rehydrate", { + text = text, + session = session_id, + }) + if not payload then + core.log.warn("ai-peyeeye: /v1/rehydrate failed: ", err) + return text + end + local out = payload.text + if type(out) == "string" then + return out + end + core.log.warn("ai-peyeeye: /v1/rehydrate returned unexpected shape; " .. + "leaving response as-is") + return text +end + + +local function delete_session(conf, session_id) + -- DELETE only applies to stateful sessions. Stateless skey_ blobs have no + -- server-side state to release. + if not session_id or session_id == "" then + return + end + if string.sub(session_id, 1, 4) ~= "ses_" then + return + end + local _, err = peyeeye_request(conf, "DELETE", + "/v1/sessions/" .. session_id, nil) + if err then + core.log.warn("ai-peyeeye: best-effort session delete failed: ", err) + end +end + + +-- Replace OpenAI-chat-style choices[].message.content (and multimodal text +-- parts) in-place. Returns the modified body or nil on no-op. +local function rehydrate_chat_body(conf, decoded, session_id) + local touched = false + if type(decoded) ~= "table" then + return nil, touched + end + local choices = decoded.choices + if type(choices) ~= "table" then + return nil, touched + end + for _, choice in ipairs(choices) do + if type(choice) == "table" and type(choice.message) == "table" then + local content = choice.message.content + if type(content) == "string" and content ~= "" then + choice.message.content = rehydrate_text(conf, content, session_id) + touched = true + elseif type(content) == "table" then + for _, part in ipairs(content) do + if type(part) == "table" and part.type == "text" + and type(part.text) == "string" and part.text ~= "" then + part.text = rehydrate_text(conf, part.text, session_id) + touched = true + end + end + end + end + end + return decoded, touched +end + + +function _M.lua_body_filter(conf, ctx, headers, body) + local session_id = ctx.peyeeye_session_id + if not session_id then + return + end + + -- Don't try to rehydrate upstream errors. + if ngx.status >= 400 then + ctx.peyeeye_session_id = nil + return + end + + if type(body) ~= "string" or body == "" then + return + end + + local decoded, decode_err = core.json.decode(body) + if not decoded then + core.log.warn("ai-peyeeye: failed to decode response body for rehydration: ", + decode_err) + return + end + + local new_decoded, touched = rehydrate_chat_body(conf, decoded, session_id) + if not touched or not new_decoded then + return + end + + local new_raw, encode_err = core.json.encode(new_decoded) + if not new_raw then + core.log.warn("ai-peyeeye: failed to re-encode rehydrated body: ", encode_err) + return + end + + -- Best-effort cleanup of the stateful session. Done after rehydrate so a + -- failure here can never leak placeholders into the client response. + if ctx.peyeeye_session_mode == "stateful" then + delete_session(conf, session_id) + end + ctx.peyeeye_session_id = nil + + return ngx_ok, new_raw +end + + +-- Suppress unused-warnings for ipairs/pairs in some lua-check configs. +local _ = pairs + +return _M diff --git a/conf/config.yaml.example b/conf/config.yaml.example index ae7155a86b06..5f9314a37d1f 100644 --- a/conf/config.yaml.example +++ b/conf/config.yaml.example @@ -514,6 +514,7 @@ plugins: # plugin list (sorted by priority) - ai-prompt-template # priority: 1071 - ai-prompt-decorator # priority: 1070 - ai-prompt-guard # priority: 1072 + - ai-peyeeye # priority: 1074 - ai-rag # priority: 1060 - ai-aws-content-moderation # priority: 1050 - ai-proxy-multi # priority: 1041 diff --git a/docs/en/latest/config.json b/docs/en/latest/config.json index d24eacc3f8e9..cbccc798fe6b 100644 --- a/docs/en/latest/config.json +++ b/docs/en/latest/config.json @@ -75,6 +75,7 @@ "plugins/ai-proxy-multi", "plugins/ai-rate-limiting", "plugins/ai-prompt-guard", + "plugins/ai-peyeeye", "plugins/ai-aws-content-moderation", "plugins/ai-aliyun-content-moderation", "plugins/ai-prompt-decorator", diff --git a/docs/en/latest/plugins/ai-peyeeye.md b/docs/en/latest/plugins/ai-peyeeye.md new file mode 100644 index 000000000000..ee5ab6091b68 --- /dev/null +++ b/docs/en/latest/plugins/ai-peyeeye.md @@ -0,0 +1,123 @@ +--- +title: ai-peyeeye +keywords: + - Apache APISIX + - API Gateway + - Plugin + - ai-peyeeye + - PII +description: This document contains information about the Apache APISIX ai-peyeeye Plugin. +--- + + + +## Description + +The `ai-peyeeye` Plugin redacts PII from prompts before they reach the upstream +LLM and rehydrates the model's response so end users see the original values. + +It calls the [peyeeye.ai](https://peyeeye.ai) `/v1/redact` and `/v1/rehydrate` +HTTP API. Two session modes are supported: + +- `stateful` (default): peyeeye stores the token-to-value map under a `ses_…` + id; the rehydrate request references the id. +- `stateless`: peyeeye returns a sealed `skey_…` blob and retains nothing + server-side. + +The Plugin is designed to be used together with the +[`ai-proxy`](./ai-proxy.md) or [`ai-proxy-multi`](./ai-proxy-multi.md) Plugin +on the same Route. It runs at priority `1074`, ahead of `ai-proxy` (1040), so +the redacted prompt is what the AI provider sees. + +### Behavior invariants + +- **Length-guard.** If `/v1/redact` returns a different number of texts than + were sent, or returns an unexpected response shape, the Plugin fails the + request with `HTTP 500` rather than forwarding partially-redacted (or + unredacted) text upstream. +- **Auth required.** If `api_key` is not supplied (in config or via the + `PEYEEYE_API_KEY` environment variable), schema validation fails. +- **Best-effort rehydrate.** If `/v1/rehydrate` fails the Plugin leaves the + model's redacted output unchanged rather than risk leaking PII. +- **Best-effort cleanup.** Stateful sessions are `DELETE`'d after rehydrate; + failures are logged but do not affect the response. + +## Plugin Attributes + +| Name | Type | Required | Default | Valid values | Description | +| --- | --- | --- | --- | --- | --- | +| `api_key` | string | True (or `PEYEEYE_API_KEY` env) | | | peyeeye API key; sent as `Authorization: Bearer `. | +| `api_base` | string | False | `https://api.peyeeye.ai` | | Override the peyeeye API base URL (e.g. for self-hosted instances or test fixtures). | +| `locale` | string | False | `auto` | BCP-47 | Locale hint passed to `/v1/redact`. | +| `entities` | array[string] | False | | | Optional whitelist of peyeeye entity ids to detect. When omitted the server uses its default set. | +| `session_mode` | string | False | `stateful` | `stateful`, `stateless` | Whether peyeeye retains the token map (`stateful`) or returns a sealed blob (`stateless`). | +| `timeout` | integer | False | 15000 | >= 1 | HTTP timeout in milliseconds for calls to the peyeeye API. | +| `keepalive` | boolean | False | true | | Reuse upstream connection pool. | +| `keepalive_pool` | integer | False | 30 | >= 1 | Connection pool size when `keepalive` is true. | +| `keepalive_timeout` | integer | False | 60000 | >= 1000 | Idle keepalive timeout in milliseconds. | +| `ssl_verify` | boolean | False | true | | Whether to verify the peyeeye TLS certificate. | + +The `api_key` field is encrypted at rest when `data_encryption` is enabled. + +## Example + +The following Route redacts PII via peyeeye and proxies to OpenAI: + +```shell +curl "http://127.0.0.1:9180/apisix/admin/routes/1" -X PUT \ + -H "X-API-KEY: ${admin_key}" \ + -d '{ + "uri": "/anything", + "methods": ["POST"], + "plugins": { + "ai-peyeeye": { + "api_key": "'"$PEYEEYE_API_KEY"'", + "session_mode": "stateful" + }, + "ai-proxy": { + "provider": "openai", + "auth": { + "header": { + "Authorization": "Bearer '"$OPENAI_API_KEY"'" + } + }, + "options": { + "model": "gpt-4o-mini" + } + } + } + }' +``` + +A request like: + +```shell +curl "http://127.0.0.1:9080/anything" -X POST \ + -H "Content-Type: application/json" \ + -d '{ + "messages": [ + { "role": "user", "content": "My email is alice@example.com, please summarise it." } + ] + }' +``` + +is rewritten to `My email is [EMAIL_1], please summarise it.` before reaching +OpenAI. The response is then rewritten in the reverse direction so the client +sees the original email address. diff --git a/docs/zh/latest/config.json b/docs/zh/latest/config.json index 1ab36fa2d43a..74f98feaf4a4 100644 --- a/docs/zh/latest/config.json +++ b/docs/zh/latest/config.json @@ -66,6 +66,7 @@ "plugins/ai-proxy-multi", "plugins/ai-rate-limiting", "plugins/ai-prompt-guard", + "plugins/ai-peyeeye", "plugins/ai-aws-content-moderation", "plugins/ai-aliyun-content-moderation", "plugins/ai-prompt-decorator", diff --git a/docs/zh/latest/plugins/ai-peyeeye.md b/docs/zh/latest/plugins/ai-peyeeye.md new file mode 100644 index 000000000000..990fb8434d18 --- /dev/null +++ b/docs/zh/latest/plugins/ai-peyeeye.md @@ -0,0 +1,119 @@ +--- +title: ai-peyeeye +keywords: + - Apache APISIX + - API 网关 + - 插件 + - ai-peyeeye + - PII +description: 本文介绍了 Apache APISIX ai-peyeeye 插件的相关操作,你可以使用此插件在请求转发到 LLM 之前,调用 peyeeye.ai 对消息中的个人身份信息(PII)进行脱敏,并在响应返回时进行还原。 +--- + + + +## 描述 + +`ai-peyeeye` 插件在请求转发到上游 LLM 之前,将提示词中的 PII 信息脱敏,并在 +响应返回客户端时将占位符还原为原始值。 + +插件调用 [peyeeye.ai](https://peyeeye.ai) 的 `/v1/redact` 与 `/v1/rehydrate` +HTTP API,支持两种会话模式: + +- `stateful`(默认):peyeeye 在服务端保存 token 到原始值的映射,并返回 + `ses_…` 会话 ID; +- `stateless`:peyeeye 返回一个加密封装的 `skey_…` 字符串,服务端不保留任何 + 状态。 + +该插件需与同一路由上的 [`ai-proxy`](./ai-proxy.md) 或 +[`ai-proxy-multi`](./ai-proxy-multi.md) 插件一起使用,其优先级为 `1074`, +高于 `ai-proxy` (1040),因此 AI 服务收到的将是已脱敏的请求。 + +### 行为不变量 + +- **长度保护**:若 `/v1/redact` 返回的文本数量与请求不一致,或返回结构异常, + 插件将以 `HTTP 500` 拒绝请求,绝不向上游转发未脱敏内容。 +- **必须配置鉴权**:若 `api_key` 未配置且环境变量 `PEYEEYE_API_KEY` 未设置, + schema 校验失败。 +- **还原失败回退**:当 `/v1/rehydrate` 调用失败时,保留模型的已脱敏输出, + 避免泄露 PII。 +- **会话清理**:有状态会话在还原后会发起最优先级的 `DELETE` 调用,失败仅记录 + 日志,不影响响应。 + +## 属性 + +| 名称 | 类型 | 必选 | 默认值 | 有效值 | 描述 | +| --- | --- | --- | --- | --- | --- | +| `api_key` | string | 是(或 `PEYEEYE_API_KEY` 环境变量) | | | peyeeye API Key,作为 `Authorization: Bearer ` 头发送。 | +| `api_base` | string | 否 | `https://api.peyeeye.ai` | | peyeeye API 基础 URL(可用于自托管实例或测试环境)。 | +| `locale` | string | 否 | `auto` | BCP-47 | 传给 `/v1/redact` 的语言提示。 | +| `entities` | array[string] | 否 | | | 可选的实体白名单,仅检测列表中的实体;缺省时使用服务端默认集合。 | +| `session_mode` | string | 否 | `stateful` | `stateful`, `stateless` | 是否在服务端保存 token 映射。 | +| `timeout` | integer | 否 | 15000 | >= 1 | 调用 peyeeye API 的 HTTP 超时(毫秒)。 | +| `keepalive` | boolean | 否 | true | | 是否使用上游连接池。 | +| `keepalive_pool` | integer | 否 | 30 | >= 1 | 连接池大小。 | +| `keepalive_timeout` | integer | 否 | 60000 | >= 1000 | 空闲 keepalive 超时(毫秒)。 | +| `ssl_verify` | boolean | 否 | true | | 是否校验 peyeeye TLS 证书。 | + +启用 `data_encryption` 时 `api_key` 字段会在存储中加密。 + +## 示例 + +下面的路由配置使用 peyeeye 进行 PII 脱敏,并通过 `ai-proxy` 转发到 OpenAI: + +```shell +curl "http://127.0.0.1:9180/apisix/admin/routes/1" -X PUT \ + -H "X-API-KEY: ${admin_key}" \ + -d '{ + "uri": "/anything", + "methods": ["POST"], + "plugins": { + "ai-peyeeye": { + "api_key": "'"$PEYEEYE_API_KEY"'", + "session_mode": "stateful" + }, + "ai-proxy": { + "provider": "openai", + "auth": { + "header": { + "Authorization": "Bearer '"$OPENAI_API_KEY"'" + } + }, + "options": { + "model": "gpt-4o-mini" + } + } + } + }' +``` + +请求示例: + +```shell +curl "http://127.0.0.1:9080/anything" -X POST \ + -H "Content-Type: application/json" \ + -d '{ + "messages": [ + { "role": "user", "content": "我的邮箱是 alice@example.com,请帮我总结一下。" } + ] + }' +``` + +会被改写为 `我的邮箱是 [EMAIL_1],请帮我总结一下。` 再发往 OpenAI;响应阶段 +则反向替换,客户端最终看到原始邮箱地址。 diff --git a/t/admin/plugins.t b/t/admin/plugins.t index adb98b28bc17..d9c15fa7ef88 100644 --- a/t/admin/plugins.t +++ b/t/admin/plugins.t @@ -98,6 +98,7 @@ ai-request-rewrite ai-prompt-guard ai-prompt-template ai-prompt-decorator +ai-peyeeye ai-rag ai-aws-content-moderation ai-proxy-multi diff --git a/t/plugin/ai-peyeeye.t b/t/plugin/ai-peyeeye.t new file mode 100644 index 000000000000..8631690f50f1 --- /dev/null +++ b/t/plugin/ai-peyeeye.t @@ -0,0 +1,445 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +BEGIN { + $ENV{TEST_ENABLE_CONTROL_API_V1} = "0"; +} + +use t::APISIX 'no_plan'; + +log_level("info"); +repeat_each(1); +no_long_string(); +no_root_location(); + + +add_block_preprocessor(sub { + my ($block) = @_; + + if (!defined $block->request) { + $block->set_value("request", "GET /t"); + } + + if (!$block->no_error_log) { + $block->set_value("no_error_log", "[error]\n[alert]"); + } + + # Mock peyeeye API on :6725. + # + # /v1/redact echoes one placeholder per input text. The fixture lets us + # exercise the length-guard branches without round-tripping the real API: + # * X-PEyeEye-Mode: bad-shape -> returns text as a string instead of an array + # * X-PEyeEye-Mode: short -> drops the last redacted text + # * (default) -> one [PII_n] placeholder per input + # + # /v1/rehydrate replaces [PII_n] occurrences in text with the literal + # string "" so tests can assert on observable output. + my $http_config = $block->http_config // <<_EOC_; + server { + listen 6725; + + default_type 'application/json'; + + location /v1/redact { + content_by_lua_block { + local core = require("apisix.core") + ngx.req.read_body() + local body = ngx.req.get_body_data() or "{}" + local data = core.json.decode(body) or {} + local mode = ngx.req.get_headers()["X-PEyeEye-Mode"] + + -- check Authorization header is present + local auth = ngx.req.get_headers()["Authorization"] + if not auth or not auth:find("Bearer ", 1, true) then + ngx.status = 401 + ngx.say(core.json.encode({error = "missing bearer"})) + return + end + + if mode == "bad-shape" then + ngx.say(core.json.encode({ + text = "not-an-array", + session_id = "ses_bad", + })) + return + end + + local out = {} + if type(data.text) == "table" then + for i, _ in ipairs(data.text) do + out[i] = "[PII_" .. i .. "]" + end + end + if mode == "short" and #out > 0 then + out[#out] = nil + end + + if data.session == "stateless" then + ngx.say(core.json.encode({ + text = out, + rehydration_key = "skey_stateless_42", + })) + else + ngx.say(core.json.encode({ + text = out, + session_id = "ses_redact_42", + })) + end + } + } + + location /v1/rehydrate { + content_by_lua_block { + local core = require("apisix.core") + ngx.req.read_body() + local body = ngx.req.get_body_data() or "{}" + local data = core.json.decode(body) or {} + local text = data.text or "" + local replaced + text, replaced = string.gsub(text, "%[PII_%d+%]", "") + ngx.say(core.json.encode({text = text, replaced = replaced})) + } + } + + location ~ ^/v1/sessions/ses_ { + content_by_lua_block { + if ngx.req.get_method() ~= "DELETE" then + ngx.status = 405 + return + end + ngx.status = 204 + } + } + } + + # Fake LLM upstream for the integration test. Replies with a JSON body + # that embeds [PII_1] so the rehydrate step has something to replace. + server { + listen 6726; + default_type 'application/json'; + + location /v1/chat/completions { + content_by_lua_block { + local core = require("apisix.core") + ngx.req.read_body() + local raw = ngx.req.get_body_data() or "{}" + -- Echo the request back so tests can assert that the body + -- forwarded to the model has been redacted. + local req = core.json.decode(raw) or {} + local first = "" + if type(req.messages) == "table" and req.messages[1] + and type(req.messages[1].content) == "string" then + first = req.messages[1].content + end + ngx.say(core.json.encode({ + id = "chatcmpl-test", + object = "chat.completion", + choices = { + { + index = 0, + message = { + role = "assistant", + content = "echo: " .. first, + }, + finish_reason = "stop", + }, + }, + usage = { + prompt_tokens = 1, + completion_tokens = 2, + total_tokens = 3, + }, + })) + } + } + } +_EOC_ + + $block->set_value("http_config", $http_config); +}); + +run_tests; + +__DATA__ + +=== TEST 1: schema validation rejects config with no api_key and no env var +--- config + location /t { + content_by_lua_block { + local plugin = require("apisix.plugins.ai-peyeeye") + -- ensure env is unset for this check + local ok, err = plugin.check_schema({}) + if ok then + ngx.say("unexpectedly accepted") + else + ngx.say(err) + end + } + } +--- response_body_like +.*api_key is required.* + + + +=== TEST 2: schema validation accepts a minimal config +--- config + location /t { + content_by_lua_block { + local plugin = require("apisix.plugins.ai-peyeeye") + local ok, err = plugin.check_schema({ + api_key = "test-key", + api_base = "http://127.0.0.1:6725", + }) + if ok then + ngx.say("ok") + else + ngx.say(err) + end + } + } +--- response_body +ok + + + +=== TEST 3: schema validation rejects unknown session_mode +--- config + location /t { + content_by_lua_block { + local plugin = require("apisix.plugins.ai-peyeeye") + local ok, err = plugin.check_schema({ + api_key = "test-key", + session_mode = "garbage", + }) + if ok then + ngx.say("unexpectedly accepted") + else + ngx.say("rejected") + end + } + } +--- response_body +rejected + + + +=== TEST 4: set up a route with ai-peyeeye + ai-proxy (stateful) +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/routes/1', + ngx.HTTP_PUT, + [[{ + "uri": "/chat", + "plugins": { + "ai-peyeeye": { + "api_key": "test-key", + "api_base": "http://127.0.0.1:6725", + "ssl_verify": false + }, + "ai-proxy": { + "provider": "openai", + "auth": { + "header": { + "Authorization": "Bearer wrongtoken" + } + }, + "override": { + "endpoint": "http://127.0.0.1:6726/v1/chat/completions" + } + } + } + }]] + ) + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- response_body +passed + + + +=== TEST 5: stateful redact + rehydrate end-to-end +--- request +POST /chat +{ "messages": [ { "role": "user", "content": "My email is alice@example.com" } ] } +--- error_code: 200 +--- response_body_like +.*.* + + + +=== TEST 6: redact with mismatched length must fail closed (no upstream call) +--- request +POST /chat +{ "messages": [ { "role": "user", "content": "first" }, { "role": "user", "content": "second" } ] } +--- more_headers +X-PEyeEye-Mode: short +--- error_code: 500 +--- response_body_like +.*refusing to forward unredacted text.* + + + +=== TEST 7: redact with unexpected response shape must fail closed +--- request +POST /chat +{ "messages": [ { "role": "user", "content": "hi" } ] } +--- more_headers +X-PEyeEye-Mode: bad-shape +--- error_code: 500 +--- response_body_like +.*refusing to forward unredacted text.* + + + +=== TEST 8: stateless mode uses skey_ rehydration key (no DELETE call) +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/routes/1', + ngx.HTTP_PUT, + [[{ + "uri": "/chat", + "plugins": { + "ai-peyeeye": { + "api_key": "test-key", + "api_base": "http://127.0.0.1:6725", + "session_mode": "stateless", + "ssl_verify": false + }, + "ai-proxy": { + "provider": "openai", + "auth": { + "header": { + "Authorization": "Bearer wrongtoken" + } + }, + "override": { + "endpoint": "http://127.0.0.1:6726/v1/chat/completions" + } + } + } + }]] + ) + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- response_body +passed + + + +=== TEST 9: stateless redact + rehydrate end-to-end +--- request +POST /chat +{ "messages": [ { "role": "user", "content": "card 4242-4242-4242-4242" } ] } +--- error_code: 200 +--- response_body_like +.*.* + + + +=== TEST 10: empty body short-circuits without calling peyeeye +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/routes/1', + ngx.HTTP_PUT, + [[{ + "uri": "/chat-empty", + "plugins": { + "ai-peyeeye": { + "api_key": "test-key", + "api_base": "http://127.0.0.1:6725", + "ssl_verify": false + } + }, + "upstream": { + "type": "roundrobin", + "nodes": { + "127.0.0.1:1980": 1 + } + } + }]] + ) + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- response_body +passed + + + +=== TEST 11: GET request with no body bypasses redaction silently +--- request +GET /chat-empty +--- error_code: 404 + + + +=== TEST 12: 401 from peyeeye fails closed +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/routes/1', + ngx.HTTP_PUT, + [[{ + "uri": "/chat", + "plugins": { + "ai-peyeeye": { + "api_key": "", + "api_base": "http://127.0.0.1:6725", + "ssl_verify": false + }, + "ai-proxy": { + "provider": "openai", + "auth": { + "header": { + "Authorization": "Bearer wrongtoken" + } + }, + "override": { + "endpoint": "http://127.0.0.1:6726/v1/chat/completions" + } + } + } + }]] + ) + -- empty api_key + no env should be rejected at schema time + if code >= 300 then + ngx.say("rejected") + else + ngx.say(body) + end + } + } +--- response_body +rejected