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