This is an automated email from the ASF dual-hosted git repository.

nic-6443 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/apisix.git


The following commit(s) were added to refs/heads/master by this push:
     new 30ed8d7d1 feat: add ai-cache plugin (#13578)
30ed8d7d1 is described below

commit 30ed8d7d152256532c3711d31f8335b4501d4966
Author: Mohammad Izzraff Janius 
<[email protected]>
AuthorDate: Mon Jun 29 10:59:01 2026 +0900

    feat: add ai-cache plugin (#13578)
---
 Makefile                             |    3 +
 apisix/cli/config.lua                |    1 +
 apisix/core/json.lua                 |   48 ++
 apisix/plugins/ai-cache.lua          |  228 ++++++
 apisix/plugins/ai-cache/key.lua      |  130 +++
 apisix/plugins/ai-cache/schema.lua   |   89 +++
 apisix/plugins/ai-transport/http.lua |   36 +-
 conf/config.yaml.example             |    1 +
 docs/en/latest/config.json           |    1 +
 docs/en/latest/plugins/ai-cache.md   |  335 ++++++++
 docs/zh/latest/config.json           |    1 +
 docs/zh/latest/plugins/ai-cache.md   |  335 ++++++++
 t/admin/plugins.t                    |    1 +
 t/plugin/ai-cache.t                  | 1448 ++++++++++++++++++++++++++++++++++
 14 files changed, 2622 insertions(+), 35 deletions(-)

diff --git a/Makefile b/Makefile
index 28a76a3e2..733f62d20 100644
--- a/Makefile
+++ b/Makefile
@@ -401,6 +401,9 @@ install: runtime
        $(ENV_INSTALL) -d $(ENV_INST_LUADIR)/apisix/plugins/ai-rag/vector-search
        $(ENV_INSTALL) apisix/plugins/ai-rag/vector-search/*.lua 
$(ENV_INST_LUADIR)/apisix/plugins/ai-rag/vector-search
 
+       $(ENV_INSTALL) -d $(ENV_INST_LUADIR)/apisix/plugins/ai-cache
+       $(ENV_INSTALL) apisix/plugins/ai-cache/*.lua 
$(ENV_INST_LUADIR)/apisix/plugins/ai-cache
+
        $(ENV_INSTALL) -d $(ENV_INST_LUADIR)/apisix/plugins/ai-lakera-guard
        $(ENV_INSTALL) apisix/plugins/ai-lakera-guard/*.lua 
$(ENV_INST_LUADIR)/apisix/plugins/ai-lakera-guard
 
diff --git a/apisix/cli/config.lua b/apisix/cli/config.lua
index 5c9d60e27..bdc22f107 100644
--- a/apisix/cli/config.lua
+++ b/apisix/cli/config.lua
@@ -244,6 +244,7 @@ local _M = {
     "ai-rate-limiting",
     "ai-proxy-multi",
     "ai-proxy",
+    "ai-cache",
     "ai-aws-content-moderation",
     "ai-aliyun-content-moderation",
     "ai-lakera-guard",
diff --git a/apisix/core/json.lua b/apisix/core/json.lua
index 397b80191..9418917b3 100644
--- a/apisix/core/json.lua
+++ b/apisix/core/json.lua
@@ -24,12 +24,19 @@ local json_encode = cjson.encode
 local json_decode = cjson.decode
 local cjson_null = cjson.null
 local clear_tab = require("table.clear")
+local require = require
 local ngx = ngx
 local tostring = tostring
 local type = type
 local pairs = pairs
+local ipairs = ipairs
+local getmetatable = getmetatable
 local cached_tab = {}
 
+local rapidjson
+local rapidjson_null
+local rapidjson_encode_opts = { sort_keys = true }
+
 
 cjson.encode_escape_forward_slash(false)
 cjson.decode_array_with_array_mt(true)
@@ -122,6 +129,47 @@ local function encode(data, force)
 end
 _M.encode = encode
 
+
+local function to_rapidjson_value(data)
+    if data == cjson_null then
+        return rapidjson_null
+    end
+
+    if type(data) ~= "table" then
+        return data
+    end
+
+    if getmetatable(data) == cjson.array_mt then
+        local arr = {}
+        for i, v in ipairs(data) do
+            arr[i] = to_rapidjson_value(v)
+        end
+        return rapidjson.array(arr)
+    end
+
+    local obj = {}
+    for k, v in pairs(data) do
+        obj[k] = to_rapidjson_value(v)
+    end
+    return obj
+end
+
+
+--- Encode a Lua value to a canonical JSON string with sorted object keys.
+-- Unlike core.json.encode, object keys are emitted in a stable (sorted) order,
+-- so the same logical value always produces the same string -- suitable for
+-- hashing, cache keys and signatures. cjson null / array_mt markers are
+-- preserved. Backed by rapidjson, which is loaded on first use.
+-- @tparam table data The value to encode.
+-- @treturn string The canonically-encoded JSON string.
+function _M.canonical_encode(data)
+    if not rapidjson then
+        rapidjson = require("rapidjson")
+        rapidjson_null = rapidjson.null
+    end
+    return rapidjson.encode(to_rapidjson_value(data), rapidjson_encode_opts)
+end
+
 local max_delay_encode_items = 16
 local delay_tab_idx = 0
 local delay_tab_arr = {}
diff --git a/apisix/plugins/ai-cache.lua b/apisix/plugins/ai-cache.lua
new file mode 100644
index 000000000..ba6f38b43
--- /dev/null
+++ b/apisix/plugins/ai-cache.lua
@@ -0,0 +1,228 @@
+--
+-- 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.
+--
+
+local core       = require("apisix.core")
+local schema     = require("apisix.plugins.ai-cache.schema")
+local key_mod    = require("apisix.plugins.ai-cache.key")
+local binding    = require("apisix.plugins.ai-protocols.binding")
+local redis_util = require("apisix.utils.redis")
+
+local ngx        = ngx
+local ngx_null   = ngx.null
+local ipairs     = ipairs
+local concat     = table.concat
+
+local CACHE_STATUS_HEADER = "X-AI-Cache-Status"
+local CACHE_AGE_HEADER    = "X-AI-Cache-Age"
+local DEFAULT_TTL         = 3600
+local DEFAULT_MAX_BODY    = 1048576
+
+local _M = {
+    version  = 0.1,
+    priority = 1035,
+    name     = "ai-cache",
+    schema   = schema,
+}
+
+
+function _M.check_schema(conf)
+    return core.schema.check(schema, conf)
+end
+
+
+local function release(conf, red)
+    local ok, err = red:set_keepalive(conf.redis_keepalive_timeout or 10000,
+                                      conf.redis_keepalive_pool or 100)
+    if not ok then
+        core.log.warn("ai-cache: failed to set redis keepalive: ", err)
+    end
+end
+
+
+local function serve_hit(conf, ctx, cached)
+    ctx.ai_cache_status = "HIT"
+    if conf.cache_headers ~= false then
+        core.response.set_header(CACHE_STATUS_HEADER, "HIT")
+        local age = ngx.time() - (cached.created_at or ngx.time())
+        core.response.set_header(CACHE_AGE_HEADER, age < 0 and 0 or age)
+    end
+    core.response.set_header("Content-Type", "application/json")
+    return core.response.exit(200, cached.body)
+end
+
+
+function _M.access(conf, ctx)
+    if not ctx.picked_ai_instance then
+        local handled, code, body = binding.on_unsupported(
+            conf.fail_mode, _M.name, ctx,
+            "no ai instance picked (request did not pass through 
ai-proxy/ai-proxy-multi)",
+            500, "ai-cache must be used with the ai-proxy or ai-proxy-multi 
plugin")
+        if handled then
+            return code, body
+        end
+        ctx.ai_cache_status = "BYPASS"
+        return
+    end
+
+    -- Streaming responses are not cached in PR-1 (SSE replay is a later
+    -- increment). ai-proxy (higher priority) has already classified the
+    -- request, so bypass before doing any work.
+    if ctx.var.request_type == "ai_stream" then
+        ctx.ai_cache_status = "BYPASS"
+        return
+    end
+
+    if conf.bypass_on then
+        for _, rule in ipairs(conf.bypass_on) do
+            if core.request.header(ctx, rule.header) == rule.equals then
+                ctx.ai_cache_status = "BYPASS"
+                return
+            end
+        end
+    end
+
+    local body, err = core.request.get_json_request_body_table()
+    if not body then
+        core.log.warn("ai-cache: cannot read request body, bypassing: ", err)
+        ctx.ai_cache_status = "BYPASS"
+        return
+    end
+
+    ctx.ai_cache_fingerprint = key_mod.fingerprint(ctx, body)
+    ctx.ai_cache_key = key_mod.build(conf, ctx, ctx.ai_cache_fingerprint)
+    -- Remember which instance the fingerprint was computed for. ai-proxy-multi
+    -- may fall back to a different instance in before_proxy; the log phase 
uses
+    -- this to avoid writing that fallback response under the original key.
+    ctx.ai_cache_picked_at_access = ctx.picked_ai_instance
+
+    local red
+    red, err = redis_util.new(conf)
+    if not red then
+        -- fail-open: never let a cache-backend outage break the request.
+        core.log.warn("ai-cache: redis unavailable, fail-open as MISS: ", err)
+        ctx.ai_cache_status = "MISS"
+        return
+    end
+
+    local res
+    res, err = red:get(ctx.ai_cache_key)
+    if err then
+        red:close()
+        core.log.warn("ai-cache: redis get failed, fail-open as MISS: ", err)
+        ctx.ai_cache_status = "MISS"
+        return
+    end
+    release(conf, red)
+
+    if res ~= nil and res ~= ngx_null then
+        local cached = core.json.decode(res)
+        if cached and cached.body then
+            return serve_hit(conf, ctx, cached)
+        end
+        core.log.warn("ai-cache: discarding malformed cache entry for ", 
ctx.ai_cache_key)
+    end
+
+    ctx.ai_cache_status = "MISS"
+end
+
+
+function _M.header_filter(conf, ctx)
+    if ctx.ai_cache_status and conf.cache_headers ~= false then
+        core.response.set_header(CACHE_STATUS_HEADER, ctx.ai_cache_status)
+    end
+end
+
+
+function _M.body_filter(conf, ctx)
+    -- only a MISS gets written back; HIT exited in access, BYPASS opts out.
+    if ctx.ai_cache_status ~= "MISS" or ctx.ai_cache_oversized then
+        return
+    end
+    local chunk = ngx.arg[1]
+    if chunk and #chunk > 0 then
+        local buf = ctx.ai_cache_buf
+        if not buf then
+            buf = { n = 0, bytes = 0 }
+            ctx.ai_cache_buf = buf
+        end
+        local n = buf.n + 1
+        buf.n = n
+        buf[n] = chunk
+        buf.bytes = buf.bytes + #chunk
+        if buf.bytes > (conf.max_cache_body_size or DEFAULT_MAX_BODY) then
+            ctx.ai_cache_buf = nil
+            ctx.ai_cache_oversized = true
+        end
+    end
+end
+
+
+-- The response-capturing phases (body_filter / log) run in contexts where
+-- cosockets are disabled, so the Redis write is deferred to a 0-delay timer
+-- (timers run in a light thread where cosockets are allowed).
+local function write_to_cache(premature, conf, cache_key, response_body)
+    if premature then
+        return
+    end
+    local red, err = redis_util.new(conf)
+    if not red then
+        core.log.warn("ai-cache: redis unavailable on write: ", err)
+        return
+    end
+    local envelope = core.json.encode({ body = response_body, created_at = 
ngx.time() })
+    local ttl = (conf.exact and conf.exact.ttl) or DEFAULT_TTL
+    local ok
+    ok, err = red:set(cache_key, envelope, "EX", ttl)
+    if not ok then
+        red:close()
+        core.log.warn("ai-cache: redis set failed: ", err)
+        return
+    end
+    release(conf, red)
+end
+
+
+function _M.log(conf, ctx)
+    if ctx.ai_cache_status ~= "MISS" or not ctx.ai_cache_fingerprint then
+        return
+    end
+    -- ai-proxy-multi may reassign the picked instance on fallback/retry during
+    -- before_proxy. The frozen fingerprint identifies the ORIGINAL instance, 
so a
+    -- response actually produced by a different (fallback) instance must not 
be
+    -- written under it -- that would replay the wrong instance's response on a
+    -- later hit.
+    if ctx.picked_ai_instance ~= ctx.ai_cache_picked_at_access then
+        return
+    end
+    if ngx.status ~= 200 then
+        return
+    end
+    local buf = ctx.ai_cache_buf
+    if not buf or buf.bytes == 0 then
+        return
+    end
+    local response_body = concat(buf, "", 1, buf.n)
+
+    local cache_key = key_mod.build(conf, ctx, ctx.ai_cache_fingerprint)
+    local ok, err = ngx.timer.at(0, write_to_cache, conf, cache_key, 
response_body)
+    if not ok then
+        core.log.warn("ai-cache: failed to schedule cache write: ", err)
+    end
+end
+
+
+return _M
diff --git a/apisix/plugins/ai-cache/key.lua b/apisix/plugins/ai-cache/key.lua
new file mode 100644
index 000000000..3f5d408ef
--- /dev/null
+++ b/apisix/plugins/ai-cache/key.lua
@@ -0,0 +1,130 @@
+--
+-- 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.
+--
+
+local core      = require("apisix.core")
+local protocols = require("apisix.plugins.ai-protocols")
+local sha256    = require("resty.sha256")
+local to_hex    = require("resty.string").to_hex
+
+local ipairs   = ipairs
+local pairs    = pairs
+local concat   = table.concat
+local tostring = tostring
+
+local KEY_PREFIX = "ai-cache:l1:"
+
+local _M = {}
+
+
+local function hex_digest(s)
+    local hash = sha256:new()
+    hash:update(s)
+    return to_hex(hash:final())
+end
+
+
+local function client_messages(ctx, body)
+    local proto = ctx.ai_client_protocol and 
protocols.get(ctx.ai_client_protocol)
+    if proto and proto.get_messages then
+        return proto.get_messages(body) or {}
+    end
+    return {}
+end
+
+
+-- Identity of the EFFECTIVE upstream request, reconstructed from access-time
+-- inputs only.
+--
+--   final_upstream_body = build_request(client_body, ai_client_protocol,
+--                                       instance{provider, options, override})
+--
+-- is deterministic, and ai-proxy builds it later (in before_proxy) so it 
cannot
+-- be observed here. Hashing build_request's INPUTS therefore identifies its
+-- output uniquely, without invoking the (side-effecting) builder. This is the
+-- ONLY place request-determining data lives; scope() below is pure isolation.
+function _M.fingerprint(ctx, body)
+    local inst = ctx.picked_ai_instance
+    local ov   = inst.override or {}
+
+    local params = {}
+    for k, v in pairs(body) do
+        if k ~= "messages" and k ~= "model" and k ~= "stream" then
+            params[k] = v
+        end
+    end
+
+    local repr = core.json.canonical_encode({
+        client = {
+            protocol = ctx.ai_client_protocol or "",
+            messages = client_messages(ctx, body),
+            params   = params,
+        },
+        effective = {
+            provider = inst.provider,
+            -- effective model precedence mirrors ai-proxy/base.lua exactly:
+            -- the instance's options.model wins over the client body model.
+            model       = (inst.options and inst.options.model) or body.model 
or "",
+            options     = inst.options,
+            llm_options = ov.llm_options,
+            request_body                = ov.request_body,
+            request_body_force_override = ov.request_body_force_override,
+            -- override.endpoint can carry a path/query that selects a 
different
+            -- deployment or model (azure deployment, bedrock inference-profile
+            -- ARN, vertex project/region/model), so it is 
response-determining.
+            endpoint    = ov.endpoint,
+        },
+    })
+    return hex_digest(repr)
+end
+
+
+-- Percent-encode "%", ":" and "=" (in that order) in scope values so a 
request-controlled
+-- include_vars value can't shift "name=value:" boundaries to forge another 
scope.
+local function esc(v)
+    return (tostring(v or ""):gsub("%%", "%%25"):gsub(":", "%%3A"):gsub("=", 
"%%3D"))
+end
+
+
+local function scope(conf, ctx)
+    local ck = conf.cache_key or {}
+
+    local parts = {}
+    if not ck.share_across_routes then
+        parts[#parts + 1] = "route=" .. esc(ctx.var.route_id)
+    end
+    if ck.include_consumer then
+        parts[#parts + 1] = "consumer=" .. esc(ctx.consumer_name)
+    end
+    if ck.include_vars then
+        for _, name in ipairs(ck.include_vars) do
+            parts[#parts + 1] = name .. "=" .. esc(ctx.var[name])
+        end
+    end
+
+    if #parts == 0 then
+        return "shared"
+    end
+    return concat(parts, ":")
+end
+
+
+function _M.build(conf, ctx, fingerprint)
+    return KEY_PREFIX .. scope(conf, ctx) .. ":" .. fingerprint
+end
+
+
+return _M
diff --git a/apisix/plugins/ai-cache/schema.lua 
b/apisix/plugins/ai-cache/schema.lua
new file mode 100644
index 000000000..5eb62661c
--- /dev/null
+++ b/apisix/plugins/ai-cache/schema.lua
@@ -0,0 +1,89 @@
+--
+-- 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.
+--
+
+local core         = require("apisix.core")
+local redis_schema = require("apisix.utils.redis-schema")
+local binding      = require("apisix.plugins.ai-protocols.binding")
+
+local policy_to_additional_properties = 
core.table.deepcopy(redis_schema.schema)
+
+local _M = {
+    type = "object",
+    properties = {
+        exact = {
+            type = "object",
+            properties = {
+                ttl = { type = "integer", minimum = 1, default = 3600 },
+            },
+            default = {},
+        },
+
+        cache_key = {
+            type = "object",
+            properties = {
+                share_across_routes = { type = "boolean", default = false },
+                include_consumer = { type = "boolean", default = false },
+                include_vars = {
+                    type = "array",
+                    items = { type = "string" },
+                    default = {},
+                },
+            },
+            default = {},
+        },
+
+        max_cache_body_size = {
+            type = "integer", minimum = 0, default = 1048576,
+        },
+
+        cache_headers = {
+            type = "boolean", default = true,
+        },
+
+        fail_mode = binding.schema_property("skip"),
+
+        bypass_on = {
+            type = "array",
+            minItems = 1,
+            items = {
+                type = "object",
+                properties = {
+                    header = { type = "string", minLength = 1 },
+                    equals = { type = "string" },
+                },
+                required = { "header", "equals" },
+            },
+        },
+
+        policy = {
+            type = "string",
+            enum = { "redis" },
+            default = "redis",
+        },
+    },
+    ["if"] = {
+        properties = {
+            policy = {
+                enum = { "redis" },
+            },
+        },
+    },
+    ["then"] = policy_to_additional_properties.redis,
+    encrypt_fields = { "redis_password" },
+}
+
+return _M
diff --git a/apisix/plugins/ai-transport/http.lua 
b/apisix/plugins/ai-transport/http.lua
index eb7efc34b..5ea9d2194 100644
--- a/apisix/plugins/ai-transport/http.lua
+++ b/apisix/plugins/ai-transport/http.lua
@@ -20,8 +20,6 @@
 
 local core = require("apisix.core")
 local http = require("resty.http")
-local rapidjson = require("rapidjson")
-local getmetatable = getmetatable
 local ngx_now = ngx.now
 local pairs = pairs
 local ipairs = ipairs
@@ -31,8 +29,6 @@ local str_lower = string.lower
 local tostring = tostring
 
 local _M = {}
-local rapidjson_encode_opts = {sort_keys = true}
-local rapidjson_null = rapidjson.null
 
 
 --- Map network errors to HTTP status codes.
@@ -73,38 +69,8 @@ function _M.construct_forward_headers(ext_opts_headers, ctx)
 end
 
 
-local function to_rapidjson_value(data)
-    if data == core.json.null then
-        return rapidjson_null
-    end
-
-    if type(data) ~= "table" then
-        return data
-    end
-
-    if getmetatable(data) == core.json.array_mt then
-        local arr = {}
-        for i, v in ipairs(data) do
-            arr[i] = to_rapidjson_value(v)
-        end
-        return rapidjson.array(arr)
-    end
-
-    local obj = {}
-    for k, v in pairs(data) do
-        obj[k] = to_rapidjson_value(v)
-    end
-    return obj
-end
-
-
-local function rapidjson_encode(body)
-    return rapidjson.encode(to_rapidjson_value(body), rapidjson_encode_opts)
-end
-
-
 local function encode_body(body)
-    local ok, encoded = pcall(rapidjson_encode, body)
+    local ok, encoded = pcall(core.json.canonical_encode, body)
     if ok and encoded then
         return encoded
     end
diff --git a/conf/config.yaml.example b/conf/config.yaml.example
index 0a129a5ac..efbf20d6b 100644
--- a/conf/config.yaml.example
+++ b/conf/config.yaml.example
@@ -538,6 +538,7 @@ plugins:                           # plugin list (sorted by 
priority)
   - ai-aws-content-moderation      # priority: 1050
   - ai-proxy-multi                 # priority: 1041
   - ai-proxy                       # priority: 1040
+  - ai-cache                       # priority: 1035
   - ai-rate-limiting               # priority: 1030
   - ai-aliyun-content-moderation   # priority: 1029
   - ai-lakera-guard                # priority: 1028
diff --git a/docs/en/latest/config.json b/docs/en/latest/config.json
index a881707e7..e5cfeb512 100644
--- a/docs/en/latest/config.json
+++ b/docs/en/latest/config.json
@@ -73,6 +73,7 @@
           "items": [
             "plugins/ai-proxy",
             "plugins/ai-proxy-multi",
+            "plugins/ai-cache",
             "plugins/ai-rate-limiting",
             "plugins/ai-prompt-guard",
             "plugins/ai-aws-content-moderation",
diff --git a/docs/en/latest/plugins/ai-cache.md 
b/docs/en/latest/plugins/ai-cache.md
new file mode 100644
index 000000000..bbeacdf73
--- /dev/null
+++ b/docs/en/latest/plugins/ai-cache.md
@@ -0,0 +1,335 @@
+---
+title: ai-cache
+keywords:
+  - Apache APISIX
+  - API Gateway
+  - Plugin
+  - ai-cache
+  - AI
+  - LLM
+description: The ai-cache Plugin caches LLM responses in Redis and replays 
them for later requests that resolve to the same prompt, cutting upstream token 
cost and latency.
+---
+
+<!--
+#
+# 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.
+#
+-->
+
+<head>
+  <link rel="canonical" href="https://docs.api7.ai/hub/ai-cache"; />
+</head>
+
+import Tabs from '@theme/Tabs';
+import TabItem from '@theme/TabItem';
+
+## Description
+
+The `ai-cache` Plugin caches LLM responses and replays them for later requests 
that resolve to the same prompt, cutting upstream token cost and latency for 
repetitive workloads (FAQ bots, document Q&A, translation).
+
+This release implements the **exact** cache layer (L1); a semantic cache layer 
(L2) is planned for a future release.
+
+The `ai-cache` Plugin must be used with the [`ai-proxy`](./ai-proxy.md) or 
[`ai-proxy-multi`](./ai-proxy-multi.md) Plugin.
+
+:::note
+
+By default the cache is isolated per route, so two routes never serve each 
other's entries even when they see the same protocol, model and messages. Set 
`cache_key.share_across_routes` to `true` to share one cache space across 
routes.
+
+Even with `cache_key.share_across_routes` enabled, the cache key identifies 
the *effective* upstream request — the request `ai-proxy` actually sends after 
applying the AI instance's `provider`, `options` (model, temperature, and other 
model parameters) and `override`. Routes that would call the model differently 
therefore keep separate cache entries, so one route's response is never served 
for another.
+
+:::
+
+## Attributes
+
+| Name | Type | Required | Default | Valid values | Description |
+|------|------|----------|---------|--------------|-------------|
+| exact.ttl | integer | False | 3600 | >= 1 | Time-to-live, in seconds, of an 
exact-cache entry. |
+| cache_key.share_across_routes | boolean | False | false | | By default the 
cache is isolated per route. If true, entries are shared across every route 
that computes the same key. |
+| cache_key.include_consumer | boolean | False | false | | If true, scope the 
cache per consumer so entries are not shared across consumers. |
+| cache_key.include_vars | array[string] | False | [] | | NGINX variables 
added to the cache scope (for example `["http_x_tenant"]`), isolating entries 
by their values. |
+| max_cache_body_size | integer | False | 1048576 | >= 0 | Maximum response 
body size, in bytes, to cache. Larger responses are not cached. |
+| cache_headers | boolean | False | true | | If true, add the 
`X-AI-Cache-Status` response header (and `X-AI-Cache-Age`, the entry age in 
seconds, on a hit). |
+| fail_mode | string | False | `"skip"` | `skip`, `warn`, `error` | Behavior 
when the request is not a recognized AI request that this Plugin can cache (for 
example, a request that did not pass through `ai-proxy` or `ai-proxy-multi`). 
`skip`: let the request pass through uncached; `warn`: pass through uncached 
and log a warning; `error`: reject the request. |
+| bypass_on | array[object] | False | | | Rules that skip the cache entirely 
(no lookup, no write-back) when any rule matches. |
+| bypass_on[].header | string | True | | | Request header name to match. |
+| bypass_on[].equals | string | True | | | Bypass when the request header's 
value exactly equals this string. |
+| policy | string | False | redis | redis | Storage backend. Only single-node 
`redis` is available in this release. |
+| redis_host | string | True | | | Address of the Redis node. |
+| redis_port | integer | False | 6379 | >= 1 | Port of the Redis node. |
+| redis_username | string | False | | | Username for Redis if Redis ACL is 
used. For the legacy `requirepass` method, configure only `redis_password`. |
+| redis_password | string | False | | | Password of the Redis node. Encrypted 
with AES before being stored in etcd. |
+| redis_database | integer | False | 0 | >= 0 | Database number in Redis. |
+| redis_timeout | integer | False | 1000 | >= 1 | Redis timeout value in 
milliseconds. |
+| redis_ssl | boolean | False | false | | If true, use SSL to connect to 
Redis. |
+| redis_ssl_verify | boolean | False | false | | If true, verify the Redis 
server SSL certificate. |
+| redis_keepalive_timeout | integer | False | 10000 | >= 1000 | Keepalive 
timeout, in milliseconds, for the Redis connection pool. |
+| redis_keepalive_pool | integer | False | 100 | >= 1 | Maximum number of 
connections in the Redis keepalive pool. |
+
+## Example
+
+The example below uses OpenAI as the Upstream LLM provider. Obtain an [OpenAI 
API key](https://openai.com/blog/openai-api) and save it, along with your Admin 
API key, to environment variables:
+
+```shell
+export OPENAI_API_KEY=your-openai-api-key
+export admin_key=$(yq '.deployment.admin.admin_key[0].key' conf/config.yaml | 
sed 's/"//g')
+```
+
+A Redis instance must be reachable at the configured `redis_host`.
+
+### Cache LLM Responses
+
+Create a Route to the LLM chat completion endpoint with the 
[`ai-proxy`](./ai-proxy.md) and `ai-cache` Plugins.
+
+<Tabs
+groupId="api"
+defaultValue="admin-api"
+values={[
+{label: 'Admin API', value: 'admin-api'},
+{label: 'ADC', value: 'adc'},
+{label: 'Ingress Controller', value: 'aic'}
+]}>
+
+<TabItem value="admin-api">
+
+```shell
+curl "http://127.0.0.1:9180/apisix/admin/routes"; -X PUT \
+  -H "X-API-KEY: ${admin_key}" \
+  -d '{
+    "id": "ai-cache-route",
+    "uri": "/anything",
+    "plugins": {
+      "ai-proxy": {
+        "provider": "openai",
+        "auth": { "header": { "Authorization": "Bearer '"$OPENAI_API_KEY"'" } 
},
+        "options": { "model": "gpt-4o" }
+      },
+      "ai-cache": {
+        "redis_host": "127.0.0.1"
+      }
+    }
+  }'
+```
+
+</TabItem>
+
+<TabItem value="adc">
+
+```yaml title="adc.yaml"
+services:
+  - name: ai-cache-service
+    routes:
+      - name: ai-cache-route
+        uris:
+          - /anything
+        methods:
+          - POST
+        plugins:
+          ai-proxy:
+            provider: openai
+            auth:
+              header:
+                Authorization: "Bearer ${OPENAI_API_KEY}"
+            options:
+              model: gpt-4o
+          ai-cache:
+            redis_host: 127.0.0.1
+```
+
+Synchronize the configuration to the gateway:
+
+```shell
+adc sync -f adc.yaml
+```
+
+</TabItem>
+
+<TabItem value="aic">
+
+<Tabs
+groupId="k8s-api"
+defaultValue="gateway-api"
+values={[
+{label: 'Gateway API', value: 'gateway-api'},
+{label: 'APISIX CRD', value: 'apisix-crd'}
+]}>
+
+<TabItem value="gateway-api">
+
+```yaml title="ai-cache-ic.yaml"
+apiVersion: apisix.apache.org/v1alpha1
+kind: PluginConfig
+metadata:
+  namespace: aic
+  name: ai-cache-plugin-config
+spec:
+  plugins:
+    - name: ai-cache
+      config:
+        redis_host: 127.0.0.1
+    - name: ai-proxy
+      config:
+        provider: openai
+        auth:
+          header:
+            Authorization: "Bearer your-openai-api-key"
+        options:
+          model: gpt-4o
+---
+apiVersion: gateway.networking.k8s.io/v1
+kind: HTTPRoute
+metadata:
+  namespace: aic
+  name: ai-cache-route
+spec:
+  parentRefs:
+    - name: apisix
+  rules:
+    - matches:
+        - path:
+            type: Exact
+            value: /anything
+          method: POST
+      filters:
+        - type: ExtensionRef
+          extensionRef:
+            group: apisix.apache.org
+            kind: PluginConfig
+            name: ai-cache-plugin-config
+```
+
+Apply the configuration to your cluster:
+
+```shell
+kubectl apply -f ai-cache-ic.yaml
+```
+
+</TabItem>
+
+<TabItem value="apisix-crd">
+
+```yaml title="ai-cache-ic.yaml"
+apiVersion: apisix.apache.org/v2
+kind: ApisixRoute
+metadata:
+  namespace: aic
+  name: ai-cache-route
+spec:
+  ingressClassName: apisix
+  http:
+    - name: ai-cache-route
+      match:
+        paths:
+          - /anything
+        methods:
+          - POST
+      plugins:
+        - name: ai-cache
+          enable: true
+          config:
+            redis_host: 127.0.0.1
+        - name: ai-proxy
+          enable: true
+          config:
+            provider: openai
+            auth:
+              header:
+                Authorization: "Bearer your-openai-api-key"
+            options:
+              model: gpt-4o
+```
+
+Apply the configuration to your cluster:
+
+```shell
+kubectl apply -f ai-cache-ic.yaml
+```
+
+</TabItem>
+
+</Tabs>
+
+</TabItem>
+
+</Tabs>
+
+Send a request to the Route:
+
+```shell
+curl -i "http://127.0.0.1:9080/anything"; -X POST \
+  -H "Content-Type: application/json" \
+  -d '{ "messages": [{ "role": "user", "content": "What is Apache APISIX? 
Answer in one sentence." }] }'
+```
+
+The first request is a cache miss and is proxied to the LLM. The response 
carries the `X-AI-Cache-Status: MISS` header and a body similar to the 
following:
+
+```json
+{
+  "id": "chatcmpl-DtmdUDZeSZ0t62y6BvLkSk5qfH3zA",
+  "object": "chat.completion",
+  "created": 1782187368,
+  "model": "gpt-4o-2024-08-06",
+  "choices": [
+    {
+      "index": 0,
+      "message": {
+        "role": "assistant",
+        "content": "Apache APISIX is a dynamic, cloud-native API gateway that 
provides high performance, scalability, and security for API management."
+      },
+      "finish_reason": "stop"
+    }
+  ],
+  "usage": {
+    "prompt_tokens": 19,
+    "completion_tokens": 25,
+    "total_tokens": 44
+  }
+}
+```
+
+Send the same request again. It is served from the cache without calling the 
LLM, returning the identical body with the headers:
+
+```text
+X-AI-Cache-Status: HIT
+X-AI-Cache-Age: 8
+```
+
+### Bypass the Cache
+
+To skip the cache for selected requests, add a `bypass_on` rule and update the 
Route:
+
+```shell
+curl "http://127.0.0.1:9180/apisix/admin/routes/ai-cache-route"; -X PATCH \
+  -H "X-API-KEY: ${admin_key}" \
+  -d '{
+    "plugins": {
+      "ai-cache": {
+        "redis_host": "127.0.0.1",
+        "bypass_on": [{ "header": "X-Cache-Bypass", "equals": "1" }]
+      }
+    }
+  }'
+```
+
+Send a request with the matching header:
+
+```shell
+curl -i "http://127.0.0.1:9080/anything"; -X POST \
+  -H "Content-Type: application/json" \
+  -H "X-Cache-Bypass: 1" \
+  -d '{ "messages": [{ "role": "user", "content": "What is Apache APISIX? 
Answer in one sentence." }] }'
+```
+
+The cache is skipped entirely (no lookup and no write-back), and the response 
carries the `X-AI-Cache-Status: BYPASS` header.
diff --git a/docs/zh/latest/config.json b/docs/zh/latest/config.json
index 4d3cdf1e6..b1cac9d24 100644
--- a/docs/zh/latest/config.json
+++ b/docs/zh/latest/config.json
@@ -64,6 +64,7 @@
           "items": [
             "plugins/ai-proxy",
             "plugins/ai-proxy-multi",
+            "plugins/ai-cache",
             "plugins/ai-rate-limiting",
             "plugins/ai-prompt-guard",
             "plugins/ai-aws-content-moderation",
diff --git a/docs/zh/latest/plugins/ai-cache.md 
b/docs/zh/latest/plugins/ai-cache.md
new file mode 100644
index 000000000..3793317bd
--- /dev/null
+++ b/docs/zh/latest/plugins/ai-cache.md
@@ -0,0 +1,335 @@
+---
+title: ai-cache
+keywords:
+  - Apache APISIX
+  - API 网关
+  - 插件
+  - ai-cache
+  - AI
+  - LLM
+description: ai-cache 插件将 LLM 响应缓存在 Redis 中,并在后续解析到相同提示词的请求中重放这些响应,从而降低上游的 
Token 消耗与延迟。
+---
+
+<!--
+#
+# 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.
+#
+-->
+
+<head>
+  <link rel="canonical" href="https://docs.api7.ai/hub/ai-cache"; />
+</head>
+
+import Tabs from '@theme/Tabs';
+import TabItem from '@theme/TabItem';
+
+## 描述
+
+`ai-cache` 插件缓存 LLM 响应,并在后续解析到相同提示词的请求中重放这些响应,从而为重复性工作负载(FAQ 
机器人、文档问答、翻译等)降低上游的 Token 消耗与延迟。
+
+本次发布实现了**精确**缓存层(L1);语义缓存层(L2)计划在未来的版本中提供。
+
+`ai-cache` 插件必须与 [`ai-proxy`](./ai-proxy.md) 或 
[`ai-proxy-multi`](./ai-proxy-multi.md) 插件一起使用。
+
+:::note
+
+默认情况下缓存按路由隔离,因此即使两个路由看到相同的协议、模型与消息,也不会相互返回对方的缓存条目。将 
`cache_key.share_across_routes` 设为 `true` 可让多个路由共享同一个缓存空间。
+
+即使开启 `cache_key.share_across_routes`,来自不同上游模型或 provider 
的响应也会分别存储在各自的缓存条目中,因此某个模型的响应绝不会被返回给另一个模型。
+
+:::
+
+## 属性
+
+| 名称 | 类型 | 必选项 | 默认值 | 有效值 | 描述 |
+|------|------|--------|--------|--------|------|
+| exact.ttl | integer | 否 | 3600 | >= 1 | 精确缓存条目的存活时间(TTL),单位为秒。 |
+| cache_key.share_across_routes | boolean | 否 | false | | 默认情况下缓存按路由隔离。如果为 
true,则计算出相同缓存键的所有路由之间共享缓存条目。 |
+| cache_key.include_consumer | boolean | 否 | false | | 如果为 
true,则按消费者隔离缓存,使缓存条目不会在不同消费者之间共享。 |
+| cache_key.include_vars | array[string] | 否 | [] | | 加入缓存作用域的 NGINX 变量(例如 
`["http_x_tenant"]`),按其取值隔离缓存条目。 |
+| max_cache_body_size | integer | 否 | 1048576 | >= 0 | 
允许缓存的最大响应体大小,单位为字节。超过该大小的响应不会被缓存。 |
+| cache_headers | boolean | 否 | true | | 如果为 true,则添加 `X-AI-Cache-Status` 
响应头(命中时还会添加 `X-AI-Cache-Age`,表示缓存条目的存在时长,单位为秒)。 |
+| fail_mode | string | 否 | `"skip"` | `skip`、`warn`、`error` | 当请求不是该插件可缓存的 AI 
请求时的处理行为(例如未经过 `ai-proxy` 或 `ai-proxy-multi` 
的请求)。`skip`:放行请求且不缓存;`warn`:放行不缓存并记录 warning 日志;`error`:拒绝请求。 |
+| bypass_on | array[object] | 否 | | | 当任一规则匹配时,完全跳过缓存(不查询、不回写)的规则列表。 |
+| bypass_on[].header | string | 是 | | | 要匹配的请求头名称。 |
+| bypass_on[].equals | string | 是 | | | 当该请求头的值与此字符串完全相等时,绕过缓存。 |
+| policy | string | 否 | redis | redis | 存储后端。本次发布仅支持单节点 `redis`。 |
+| redis_host | string | 是 | | | Redis 节点的地址。 |
+| redis_port | integer | 否 | 6379 | >= 1 | Redis 节点的端口。 |
+| redis_username | string | 否 | | | 使用 Redis ACL 时的用户名。如果使用传统的 `requirepass` 
认证方式,则仅配置 `redis_password`。 |
+| redis_password | string | 否 | | | Redis 节点的密码。在存入 etcd 之前使用 AES 加密。 |
+| redis_database | integer | 否 | 0 | >= 0 | Redis 中使用的数据库编号。 |
+| redis_timeout | integer | 否 | 1000 | >= 1 | Redis 超时时间,单位为毫秒。 |
+| redis_ssl | boolean | 否 | false | | 如果为 true,则使用 SSL 连接 Redis。 |
+| redis_ssl_verify | boolean | 否 | false | | 如果为 true,则校验 Redis 服务器的 SSL 证书。 |
+| redis_keepalive_timeout | integer | 否 | 10000 | >= 1000 | Redis 
连接池的保活超时时间,单位为毫秒。 |
+| redis_keepalive_pool | integer | 否 | 100 | >= 1 | Redis 保活连接池中的最大连接数。 |
+
+## 示例
+
+以下示例使用 OpenAI 作为上游 LLM 服务提供商。请获取 [OpenAI API 
key](https://openai.com/blog/openai-api),并将其与 Admin API key 一起保存到环境变量中:
+
+```shell
+export OPENAI_API_KEY=your-openai-api-key
+export admin_key=$(yq '.deployment.admin.admin_key[0].key' conf/config.yaml | 
sed 's/"//g')
+```
+
+在配置的 `redis_host` 上必须有一个可访问的 Redis 实例。
+
+### 缓存 LLM 响应
+
+使用 [`ai-proxy`](./ai-proxy.md) 和 `ai-cache` 插件创建一个指向 LLM 聊天补全端点的路由。
+
+<Tabs
+groupId="api"
+defaultValue="admin-api"
+values={[
+{label: 'Admin API', value: 'admin-api'},
+{label: 'ADC', value: 'adc'},
+{label: 'Ingress Controller', value: 'aic'}
+]}>
+
+<TabItem value="admin-api">
+
+```shell
+curl "http://127.0.0.1:9180/apisix/admin/routes"; -X PUT \
+  -H "X-API-KEY: ${admin_key}" \
+  -d '{
+    "id": "ai-cache-route",
+    "uri": "/anything",
+    "plugins": {
+      "ai-proxy": {
+        "provider": "openai",
+        "auth": { "header": { "Authorization": "Bearer '"$OPENAI_API_KEY"'" } 
},
+        "options": { "model": "gpt-4o" }
+      },
+      "ai-cache": {
+        "redis_host": "127.0.0.1"
+      }
+    }
+  }'
+```
+
+</TabItem>
+
+<TabItem value="adc">
+
+```yaml title="adc.yaml"
+services:
+  - name: ai-cache-service
+    routes:
+      - name: ai-cache-route
+        uris:
+          - /anything
+        methods:
+          - POST
+        plugins:
+          ai-proxy:
+            provider: openai
+            auth:
+              header:
+                Authorization: "Bearer ${OPENAI_API_KEY}"
+            options:
+              model: gpt-4o
+          ai-cache:
+            redis_host: 127.0.0.1
+```
+
+将配置同步到网关:
+
+```shell
+adc sync -f adc.yaml
+```
+
+</TabItem>
+
+<TabItem value="aic">
+
+<Tabs
+groupId="k8s-api"
+defaultValue="gateway-api"
+values={[
+{label: 'Gateway API', value: 'gateway-api'},
+{label: 'APISIX CRD', value: 'apisix-crd'}
+]}>
+
+<TabItem value="gateway-api">
+
+```yaml title="ai-cache-ic.yaml"
+apiVersion: apisix.apache.org/v1alpha1
+kind: PluginConfig
+metadata:
+  namespace: aic
+  name: ai-cache-plugin-config
+spec:
+  plugins:
+    - name: ai-cache
+      config:
+        redis_host: 127.0.0.1
+    - name: ai-proxy
+      config:
+        provider: openai
+        auth:
+          header:
+            Authorization: "Bearer your-openai-api-key"
+        options:
+          model: gpt-4o
+---
+apiVersion: gateway.networking.k8s.io/v1
+kind: HTTPRoute
+metadata:
+  namespace: aic
+  name: ai-cache-route
+spec:
+  parentRefs:
+    - name: apisix
+  rules:
+    - matches:
+        - path:
+            type: Exact
+            value: /anything
+          method: POST
+      filters:
+        - type: ExtensionRef
+          extensionRef:
+            group: apisix.apache.org
+            kind: PluginConfig
+            name: ai-cache-plugin-config
+```
+
+将配置应用到您的集群:
+
+```shell
+kubectl apply -f ai-cache-ic.yaml
+```
+
+</TabItem>
+
+<TabItem value="apisix-crd">
+
+```yaml title="ai-cache-ic.yaml"
+apiVersion: apisix.apache.org/v2
+kind: ApisixRoute
+metadata:
+  namespace: aic
+  name: ai-cache-route
+spec:
+  ingressClassName: apisix
+  http:
+    - name: ai-cache-route
+      match:
+        paths:
+          - /anything
+        methods:
+          - POST
+      plugins:
+        - name: ai-cache
+          enable: true
+          config:
+            redis_host: 127.0.0.1
+        - name: ai-proxy
+          enable: true
+          config:
+            provider: openai
+            auth:
+              header:
+                Authorization: "Bearer your-openai-api-key"
+            options:
+              model: gpt-4o
+```
+
+将配置应用到您的集群:
+
+```shell
+kubectl apply -f ai-cache-ic.yaml
+```
+
+</TabItem>
+
+</Tabs>
+
+</TabItem>
+
+</Tabs>
+
+向该路由发送请求:
+
+```shell
+curl -i "http://127.0.0.1:9080/anything"; -X POST \
+  -H "Content-Type: application/json" \
+  -d '{ "messages": [{ "role": "user", "content": "What is Apache APISIX? 
Answer in one sentence." }] }'
+```
+
+第一次请求是缓存未命中(MISS),会被代理到 LLM。响应中携带 `X-AI-Cache-Status: MISS` 响应头,响应体类似如下:
+
+```json
+{
+  "id": "chatcmpl-DtmdUDZeSZ0t62y6BvLkSk5qfH3zA",
+  "object": "chat.completion",
+  "created": 1782187368,
+  "model": "gpt-4o-2024-08-06",
+  "choices": [
+    {
+      "index": 0,
+      "message": {
+        "role": "assistant",
+        "content": "Apache APISIX is a dynamic, cloud-native API gateway that 
provides high performance, scalability, and security for API management."
+      },
+      "finish_reason": "stop"
+    }
+  ],
+  "usage": {
+    "prompt_tokens": 19,
+    "completion_tokens": 25,
+    "total_tokens": 44
+  }
+}
+```
+
+再次发送相同的请求。该请求将直接由缓存返回,而不会调用 LLM,返回完全相同的响应体,并携带以下响应头:
+
+```text
+X-AI-Cache-Status: HIT
+X-AI-Cache-Age: 8
+```
+
+### 绕过缓存
+
+如需为特定请求跳过缓存,可添加 `bypass_on` 规则并更新路由:
+
+```shell
+curl "http://127.0.0.1:9180/apisix/admin/routes/ai-cache-route"; -X PATCH \
+  -H "X-API-KEY: ${admin_key}" \
+  -d '{
+    "plugins": {
+      "ai-cache": {
+        "redis_host": "127.0.0.1",
+        "bypass_on": [{ "header": "X-Cache-Bypass", "equals": "1" }]
+      }
+    }
+  }'
+```
+
+发送带有匹配请求头的请求:
+
+```shell
+curl -i "http://127.0.0.1:9080/anything"; -X POST \
+  -H "Content-Type: application/json" \
+  -H "X-Cache-Bypass: 1" \
+  -d '{ "messages": [{ "role": "user", "content": "What is Apache APISIX? 
Answer in one sentence." }] }'
+```
+
+缓存被完全跳过(不查询、不回写),响应中携带 `X-AI-Cache-Status: BYPASS` 响应头。
diff --git a/t/admin/plugins.t b/t/admin/plugins.t
index ab80a63ed..ec33d940a 100644
--- a/t/admin/plugins.t
+++ b/t/admin/plugins.t
@@ -108,6 +108,7 @@ ai-rag
 ai-aws-content-moderation
 ai-proxy-multi
 ai-proxy
+ai-cache
 ai-rate-limiting
 ai-aliyun-content-moderation
 ai-lakera-guard
diff --git a/t/plugin/ai-cache.t b/t/plugin/ai-cache.t
new file mode 100644
index 000000000..ddc72eb1c
--- /dev/null
+++ b/t/plugin/ai-cache.t
@@ -0,0 +1,1448 @@
+#
+# 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");
+    }
+
+    my $user_yaml_config = <<_EOC_;
+plugins:
+  - ai-proxy
+  - ai-cache
+_EOC_
+    if (!defined $block->extra_yaml_config) {
+        $block->set_value("extra_yaml_config", $user_yaml_config);
+    }
+
+    my $http_config = $block->http_config // <<_EOC_;
+    server {
+        listen 6731;
+        default_type 'application/json';
+        location / {
+            content_by_lua_block {
+                ngx.status = 500
+                ngx.say([[{"error":{"message":"primary down"}}]])
+            }
+        }
+    }
+_EOC_
+    $block->set_value("http_config", $http_config);
+});
+
+run_tests();
+
+__DATA__
+
+=== TEST 1: minimal valid exact-cache configuration
+--- config
+    location /t {
+        content_by_lua_block {
+            local plugin = require("apisix.plugins.ai-cache")
+            local ok, err = plugin.check_schema({
+                redis_host = "127.0.0.1",
+                redis_port = 6379,
+            })
+
+            if not ok then
+                ngx.say(err)
+            else
+                ngx.say("passed")
+            end
+        }
+    }
+--- response_body
+passed
+
+
+
+=== TEST 2: reject config missing required redis (policy=redis then-clause)
+--- config
+    location /t {
+        content_by_lua_block {
+            local plugin = require("apisix.plugins.ai-cache")
+            local ok, err = plugin.check_schema({})
+
+            if not ok then
+                ngx.say(err)
+            else
+                ngx.say("passed")
+            end
+        }
+    }
+--- response_body eval
+qr/then clause did not match/
+
+
+
+=== TEST 3: reject an out-of-range exact.ttl
+--- config
+    location /t {
+        content_by_lua_block {
+            local plugin = require("apisix.plugins.ai-cache")
+            local ok, err = plugin.check_schema({
+                redis_host = "127.0.0.1",
+                exact = { ttl = 0 },
+            })
+
+            if not ok then
+                ngx.say(err)
+            else
+                ngx.say("passed")
+            end
+        }
+    }
+--- response_body eval
+qr/ttl/
+
+
+
+=== TEST 4: flush redis, then set route with ai-proxy + ai-cache (mock 
upstream)
+--- config
+    location /t {
+        content_by_lua_block {
+            require("lib.test_redis").flush_port("127.0.0.1", 6379)
+
+            local t = require("lib.test_admin").test
+            local code, body = t('/apisix/admin/routes/1',
+                ngx.HTTP_PUT,
+                [[{
+                    "uri": "/anything",
+                    "plugins": {
+                        "ai-proxy": {
+                            "provider": "openai",
+                            "auth": {
+                                "header": {
+                                    "Authorization": "Bearer test-key"
+                                }
+                            },
+                            "options": {
+                                "model": "gpt-4o"
+                            },
+                            "override": {
+                                "endpoint": "http://127.0.0.1:1980";
+                            }
+                        },
+                        "ai-cache": {
+                            "redis_host": "127.0.0.1",
+                            "redis_port": 6379
+                        }
+                    }
+                }]]
+            )
+
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+--- response_body
+passed
+
+
+
+=== TEST 5: cold request is a cache MISS and is proxied upstream
+--- request
+POST /anything
+{"model":"gpt-4o","messages":[{"role":"user","content":"ai-cache miss 
unique-prompt-5"}]}
+--- more_headers
+X-AI-Fixture: openai/chat-basic.json
+--- response_headers
+X-AI-Cache-Status: MISS
+--- response_body_like eval
+qr/1 \+ 1 = 2/
+--- wait: 0.3
+
+
+
+=== TEST 6: identical re-request is a HIT served from cache (upstream not 
called)
+--- request
+POST /anything
+{"model":"gpt-4o","messages":[{"role":"user","content":"ai-cache miss 
unique-prompt-5"}]}
+--- error_code: 200
+--- response_headers_like
+X-AI-Cache-Status: HIT
+X-AI-Cache-Age: \d+
+--- response_body_like eval
+qr/1 \+ 1 = 2/
+
+
+
+=== TEST 7: fingerprint covers the client request AND the effective instance 
config (key.lua unit)
+--- config
+    location /t {
+        content_by_lua_block {
+            local core = require("apisix.core")
+            local key = require("apisix.plugins.ai-cache.key")
+
+            -- fingerprint() identifies the effective upstream request, so it 
reads
+            -- both the client body and the picked instance 
(provider/options/override).
+            local function fp(inst, body)
+                local ctx = { ai_client_protocol = "openai-chat", var = {},
+                              picked_ai_instance = inst }
+                return key.fingerprint(ctx, body)
+            end
+
+            local inst   = { provider = "openai", options = {}, override = {} }
+            local prompt = { model = "gpt-4o", messages = {{role="user", 
content="hi"}}, temperature = 0.2 }
+            local base   = fp(inst, prompt)
+
+            -- client fields the upstream would see: a change flips the 
fingerprint
+            local other_messages = { model = "gpt-4o",      messages = 
{{role="user", content="bye"}} }
+            local other_model    = { model = "gpt-4o-mini", messages = 
{{role="user", content="hi"}}  }
+            local other_temp     = { model = "gpt-4o",      messages = 
{{role="user", content="hi"}}, temperature = 0.9 }
+            local with_stream    = { model = "gpt-4o",      messages = 
{{role="user", content="hi"}}, temperature = 0.2, stream = true }
+
+            assert(fp(inst, prompt)         == base, "identical request and 
config must match")
+            assert(fp(inst, other_messages) ~= base, "different messages")
+            assert(fp(inst, other_model)    ~= base, "different client model")
+            assert(fp(inst, other_temp)     ~= base, "different temperature")
+            assert(fp(inst, with_stream)    == base, "the stream flag is 
stripped, so it must not matter")
+
+            -- instance config that ai-proxy applies upstream: a change flips 
it too
+            local other_provider = { provider = "deepseek", options = {},      
                 override = {} }
+            local forced_model   = { provider = "openai",   options = { model 
= "gpt-4o-mini" }, override = {} }
+            local server_temp    = { provider = "openai",   options = { 
temperature = 0.9 },     override = {} }
+            local llm_opts       = { provider = "openai",   options = {}, 
override = { llm_options = { max_tokens = 16 } } }
+            local body_override  = { provider = "openai",   options = {}, 
override = { request_body = { ["openai-chat"] = { foo = "bar" } } } }
+            local endpoint_a     = { provider = "openai",   options = {}, 
override = { endpoint = "http://host/a"; } }
+            local endpoint_b     = { provider = "openai",   options = {}, 
override = { endpoint = "http://host/b"; } }
+
+            assert(fp(other_provider, prompt) ~= base, "different provider")
+            assert(fp(forced_model,   prompt) ~= base, "different effective 
model")
+            assert(fp(server_temp,    prompt) ~= base, "different server-side 
options")
+            assert(fp(llm_opts,       prompt) ~= base, "different 
override.llm_options")
+            assert(fp(body_override,  prompt) ~= base, "different 
override.request_body")
+            assert(fp(endpoint_a, prompt) ~= fp(endpoint_b, prompt), 
"different override.endpoint")
+
+            -- a forced options.model overrides the client model, so the 
client model stops mattering
+            assert(fp(forced_model, { messages = prompt.messages, model = 
"gpt-4o" })
+                   == fp(forced_model, { messages = prompt.messages, model = 
"zzz" }),
+                   "forced options.model makes the client model irrelevant")
+
+            -- an explicit JSON null must encode stably (regression for the 
rapidjson null path)
+            local with_null = core.json.decode(
+                
'{"model":"gpt-4o","messages":[{"role":"user","content":"hi"}],"stop":null}')
+            assert(fp(inst, with_null) == fp(inst, with_null), "null-bearing 
fingerprint must be stable")
+            assert(fp(inst, with_null) ~= base, "an explicit null must change 
the fingerprint")
+
+            ngx.say("passed")
+        }
+    }
+--- response_body
+passed
+
+
+
+=== TEST 8: non-2xx upstream (no fixture -> 401) is a MISS
+--- request
+POST /anything
+{"model":"gpt-4o","messages":[{"role":"user","content":"non-2xx-test-prompt"}]}
+--- error_code: 401
+--- response_headers
+X-AI-Cache-Status: MISS
+--- wait: 0.3
+
+
+
+=== TEST 9: same prompt with a valid fixture is still a MISS (the 401 was not 
cached)
+--- request
+POST /anything
+{"model":"gpt-4o","messages":[{"role":"user","content":"non-2xx-test-prompt"}]}
+--- more_headers
+X-AI-Fixture: openai/chat-basic.json
+--- error_code: 200
+--- response_headers
+X-AI-Cache-Status: MISS
+--- response_body_like eval
+qr/1 \+ 1 = 2/
+
+
+
+=== TEST 10: set route with a bypass_on header rule
+--- 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": "/anything",
+                    "plugins": {
+                        "ai-proxy": {
+                            "provider": "openai",
+                            "auth": { "header": { "Authorization": "Bearer 
test-key" } },
+                            "options": { "model": "gpt-4o" },
+                            "override": { "endpoint": "http://127.0.0.1:1980"; }
+                        },
+                        "ai-cache": {
+                            "redis_host": "127.0.0.1",
+                            "redis_port": 6379,
+                            "bypass_on": [{"header": "X-AI-Cache-Bypass", 
"equals": "1"}]
+                        }
+                    }
+                }]]
+            )
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+--- response_body
+passed
+
+
+
+=== TEST 11: a matching bypass_on header value is a BYPASS
+--- request
+POST /anything
+{"model":"gpt-4o","messages":[{"role":"user","content":"bypass rule test"}]}
+--- more_headers
+X-AI-Fixture: openai/chat-basic.json
+X-AI-Cache-Bypass: 1
+--- response_headers
+X-AI-Cache-Status: BYPASS
+
+
+
+=== TEST 12: a non-matching bypass_on header value does not bypass (normal 
MISS)
+--- request
+POST /anything
+{"model":"gpt-4o","messages":[{"role":"user","content":"bypass-nonmatch-test"}]}
+--- more_headers
+X-AI-Fixture: openai/chat-basic.json
+X-AI-Cache-Bypass: 0
+--- response_headers
+X-AI-Cache-Status: MISS
+
+
+
+=== TEST 13: set route with multiple bypass_on rules
+--- 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": "/anything",
+                    "plugins": {
+                        "ai-proxy": {
+                            "provider": "openai",
+                            "auth": { "header": { "Authorization": "Bearer 
test-key" } },
+                            "options": { "model": "gpt-4o" },
+                            "override": { "endpoint": "http://127.0.0.1:1980"; }
+                        },
+                        "ai-cache": {
+                            "redis_host": "127.0.0.1",
+                            "redis_port": 6379,
+                            "bypass_on": [
+                                {"header": "X-AI-Cache-Bypass", "equals": "1"},
+                                {"header": "X-Debug", "equals": "on"}
+                            ]
+                        }
+                    }
+                }]]
+            )
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+--- response_body
+passed
+
+
+
+=== TEST 14: any matching bypass_on rule triggers a BYPASS (second rule 
matches)
+--- request
+POST /anything
+{"model":"gpt-4o","messages":[{"role":"user","content":"any-rule-bypass-test"}]}
+--- more_headers
+X-AI-Fixture: openai/chat-basic.json
+X-Debug: on
+--- response_headers
+X-AI-Cache-Status: BYPASS
+
+
+
+=== TEST 15: set route with a tiny max_cache_body_size
+--- 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": "/anything",
+                    "plugins": {
+                        "ai-proxy": {
+                            "provider": "openai",
+                            "auth": { "header": { "Authorization": "Bearer 
test-key" } },
+                            "options": { "model": "gpt-4o" },
+                            "override": { "endpoint": "http://127.0.0.1:1980"; }
+                        },
+                        "ai-cache": {
+                            "redis_host": "127.0.0.1",
+                            "redis_port": 6379,
+                            "max_cache_body_size": 10
+                        }
+                    }
+                }]]
+            )
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+--- response_body
+passed
+
+
+
+=== TEST 16: cold request (response exceeds max_cache_body_size) is a MISS
+--- request
+POST /anything
+{"model":"gpt-4o","messages":[{"role":"user","content":"body-size-test"}]}
+--- more_headers
+X-AI-Fixture: openai/chat-basic.json
+--- response_headers
+X-AI-Cache-Status: MISS
+--- wait: 0.3
+
+
+
+=== TEST 17: same prompt is still a MISS (oversized response was not cached)
+--- request
+POST /anything
+{"model":"gpt-4o","messages":[{"role":"user","content":"body-size-test"}]}
+--- more_headers
+X-AI-Fixture: openai/chat-basic.json
+--- response_headers
+X-AI-Cache-Status: MISS
+
+
+
+=== TEST 18: set route isolating the cache by a request variable
+--- 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": "/anything",
+                    "plugins": {
+                        "ai-proxy": {
+                            "provider": "openai",
+                            "auth": { "header": { "Authorization": "Bearer 
test-key" } },
+                            "options": { "model": "gpt-4o" },
+                            "override": { "endpoint": "http://127.0.0.1:1980"; }
+                        },
+                        "ai-cache": {
+                            "redis_host": "127.0.0.1",
+                            "redis_port": 6379,
+                            "cache_key": { "include_vars": ["http_x_tenant"] }
+                        }
+                    }
+                }]]
+            )
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+--- response_body
+passed
+
+
+
+=== TEST 19: tenant alpha cold request is a MISS (warms scope=alpha)
+--- request
+POST /anything
+{"model":"gpt-4o","messages":[{"role":"user","content":"scope isolation 
test"}]}
+--- more_headers
+X-AI-Fixture: openai/chat-basic.json
+X-Tenant: alpha
+--- response_headers
+X-AI-Cache-Status: MISS
+--- wait: 0.3
+
+
+
+=== TEST 20: same prompt, tenant beta is a MISS (not shared with alpha)
+--- request
+POST /anything
+{"model":"gpt-4o","messages":[{"role":"user","content":"scope isolation 
test"}]}
+--- more_headers
+X-AI-Fixture: openai/chat-basic.json
+X-Tenant: beta
+--- response_headers
+X-AI-Cache-Status: MISS
+
+
+
+=== TEST 21: same prompt, tenant alpha is a HIT (its own scope persisted)
+--- request
+POST /anything
+{"model":"gpt-4o","messages":[{"role":"user","content":"scope isolation 
test"}]}
+--- more_headers
+X-Tenant: alpha
+--- error_code: 200
+--- response_headers
+X-AI-Cache-Status: HIT
+
+
+
+=== TEST 22: set route with a 1-second exact ttl
+--- 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": "/anything",
+                    "plugins": {
+                        "ai-proxy": {
+                            "provider": "openai",
+                            "auth": { "header": { "Authorization": "Bearer 
test-key" } },
+                            "options": { "model": "gpt-4o" },
+                            "override": { "endpoint": "http://127.0.0.1:1980"; }
+                        },
+                        "ai-cache": {
+                            "redis_host": "127.0.0.1",
+                            "redis_port": 6379,
+                            "exact": { "ttl": 1 }
+                        }
+                    }
+                }]]
+            )
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+--- response_body
+passed
+
+
+
+=== TEST 23: cold request is a MISS (cached with ttl=1), then wait past the ttl
+--- request
+POST /anything
+{"model":"gpt-4o","messages":[{"role":"user","content":"ttl-expiry-test"}]}
+--- more_headers
+X-AI-Fixture: openai/chat-basic.json
+--- response_headers
+X-AI-Cache-Status: MISS
+--- wait: 2
+
+
+
+=== TEST 24: same prompt is a MISS again (entry expired)
+--- request
+POST /anything
+{"model":"gpt-4o","messages":[{"role":"user","content":"ttl-expiry-test"}]}
+--- more_headers
+X-AI-Fixture: openai/chat-basic.json
+--- response_headers
+X-AI-Cache-Status: MISS
+
+
+
+=== TEST 25: set an anthropic-messages route (cross-protocol)
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+            local code, body = t('/apisix/admin/routes/2',
+                ngx.HTTP_PUT,
+                [[{
+                    "uri": "/v1/messages",
+                    "plugins": {
+                        "ai-proxy": {
+                            "provider": "anthropic",
+                            "auth": { "header": { "x-api-key": "test-key" } },
+                            "options": { "model": "claude-3-5-sonnet-20241022" 
},
+                            "override": { "endpoint": "http://127.0.0.1:1980"; }
+                        },
+                        "ai-cache": {
+                            "redis_host": "127.0.0.1",
+                            "redis_port": 6379
+                        }
+                    }
+                }]]
+            )
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+--- response_body
+passed
+
+
+
+=== TEST 26: anthropic cold request is a MISS
+--- request
+POST /v1/messages
+{"model":"claude-3-5-sonnet-20241022","messages":[{"role":"user","content":"cross-protocol
 test"}],"max_tokens":100}
+--- more_headers
+X-AI-Fixture: anthropic/messages-basic.json
+--- response_headers
+X-AI-Cache-Status: MISS
+--- wait: 0.3
+
+
+
+=== TEST 27: identical anthropic re-request is a HIT (upstream not called)
+--- request
+POST /v1/messages
+{"model":"claude-3-5-sonnet-20241022","messages":[{"role":"user","content":"cross-protocol
 test"}],"max_tokens":100}
+--- error_code: 200
+--- response_headers
+X-AI-Cache-Status: HIT
+
+
+
+=== TEST 28: set route whose redis is unreachable
+--- 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": "/anything",
+                    "plugins": {
+                        "ai-proxy": {
+                            "provider": "openai",
+                            "auth": { "header": { "Authorization": "Bearer 
test-key" } },
+                            "options": { "model": "gpt-4o" },
+                            "override": { "endpoint": "http://127.0.0.1:1980"; }
+                        },
+                        "ai-cache": {
+                            "redis_host": "127.0.0.1",
+                            "redis_port": 6390,
+                            "redis_timeout": 200
+                        }
+                    }
+                }]]
+            )
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+--- response_body
+passed
+
+
+
+=== TEST 29: redis unreachable fails open (request still proxied as MISS, no 
5xx)
+--- request
+POST /anything
+{"model":"gpt-4o","messages":[{"role":"user","content":"redis-down failopen"}]}
+--- more_headers
+X-AI-Fixture: openai/chat-basic.json
+--- error_code: 200
+--- response_headers
+X-AI-Cache-Status: MISS
+--- response_body_like eval
+qr/1 \+ 1 = 2/
+--- error_log
+ai-cache: redis unavailable, fail-open as MISS
+
+
+
+=== TEST 30: set route with cache_headers disabled
+--- 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": "/anything",
+                    "plugins": {
+                        "ai-proxy": {
+                            "provider": "openai",
+                            "auth": { "header": { "Authorization": "Bearer 
test-key" } },
+                            "options": { "model": "gpt-4o" },
+                            "override": { "endpoint": "http://127.0.0.1:1980"; }
+                        },
+                        "ai-cache": {
+                            "redis_host": "127.0.0.1",
+                            "redis_port": 6379,
+                            "cache_headers": false
+                        }
+                    }
+                }]]
+            )
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+--- response_body
+passed
+
+
+
+=== TEST 31: cache_headers=false suppresses the X-AI-Cache-* headers
+--- request
+POST /anything
+{"model":"gpt-4o","messages":[{"role":"user","content":"cache-headers-off-test"}]}
+--- more_headers
+X-AI-Fixture: openai/chat-basic.json
+--- error_code: 200
+--- response_headers
+! X-AI-Cache-Status
+! X-AI-Cache-Age
+--- response_body_like eval
+qr/1 \+ 1 = 2/
+
+
+
+=== TEST 32: set a default ai-proxy + ai-cache route (for status-code tests)
+--- 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": "/anything",
+                    "plugins": {
+                        "ai-proxy": {
+                            "provider": "openai",
+                            "auth": { "header": { "Authorization": "Bearer 
test-key" } },
+                            "options": { "model": "gpt-4o" },
+                            "override": { "endpoint": "http://127.0.0.1:1980"; }
+                        },
+                        "ai-cache": {
+                            "redis_host": "127.0.0.1",
+                            "redis_port": 6379
+                        }
+                    }
+                }]]
+            )
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+--- response_body
+passed
+
+
+
+=== TEST 33: a 2xx that is not 200 (201) is a MISS and is proxied through
+--- request
+POST /anything
+{"model":"gpt-4o","messages":[{"role":"user","content":"status-201-test-prompt"}]}
+--- more_headers
+X-AI-Fixture: openai/chat-basic.json
+X-AI-Fixture-Status: 201
+--- error_code: 201
+--- response_headers
+X-AI-Cache-Status: MISS
+--- wait: 0.3
+
+
+
+=== TEST 34: same prompt with a 200 fixture is still a MISS (the 201 was not 
cached)
+--- request
+POST /anything
+{"model":"gpt-4o","messages":[{"role":"user","content":"status-201-test-prompt"}]}
+--- more_headers
+X-AI-Fixture: openai/chat-basic.json
+--- error_code: 200
+--- response_headers
+X-AI-Cache-Status: MISS
+--- response_body_like eval
+qr/1 \+ 1 = 2/
+
+
+
+=== TEST 35: set two openai routes (same model, default scope) sharing one 
Redis
+--- 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": "/anything",
+                    "plugins": {
+                        "ai-proxy": {
+                            "provider": "openai",
+                            "auth": { "header": { "Authorization": "Bearer 
test-key" } },
+                            "options": { "model": "gpt-4o" },
+                            "override": { "endpoint": "http://127.0.0.1:1980"; }
+                        },
+                        "ai-cache": {
+                            "redis_host": "127.0.0.1",
+                            "redis_port": 6379
+                        }
+                    }
+                }]]
+            )
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+
+            code, body = t('/apisix/admin/routes/2',
+                ngx.HTTP_PUT,
+                [[{
+                    "uri": "/cache-route-b",
+                    "plugins": {
+                        "ai-proxy": {
+                            "provider": "openai",
+                            "auth": { "header": { "Authorization": "Bearer 
test-key" } },
+                            "options": { "model": "gpt-4o" },
+                            "override": { "endpoint": "http://127.0.0.1:1980"; }
+                        },
+                        "ai-cache": {
+                            "redis_host": "127.0.0.1",
+                            "redis_port": 6379
+                        }
+                    }
+                }]]
+            )
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+--- response_body
+passed
+passed
+
+
+
+=== TEST 36: route 1 cold request is a MISS (warms scope=route=1)
+--- request
+POST /anything
+{"model":"gpt-4o","messages":[{"role":"user","content":"cross-route isolation 
test"}]}
+--- more_headers
+X-AI-Fixture: openai/chat-basic.json
+--- response_headers
+X-AI-Cache-Status: MISS
+--- wait: 0.3
+
+
+
+=== TEST 37: same prompt on route 2 is a MISS (not shared with route 1 by 
default)
+--- request
+POST /cache-route-b
+{"model":"gpt-4o","messages":[{"role":"user","content":"cross-route isolation 
test"}]}
+--- more_headers
+X-AI-Fixture: openai/chat-basic.json
+--- response_headers
+X-AI-Cache-Status: MISS
+
+
+
+=== TEST 38: same prompt on route 1 is a HIT (its own per-route scope 
persisted)
+--- request
+POST /anything
+{"model":"gpt-4o","messages":[{"role":"user","content":"cross-route isolation 
test"}]}
+--- error_code: 200
+--- response_headers
+X-AI-Cache-Status: HIT
+
+
+
+=== TEST 39: set both routes with share_across_routes enabled
+--- 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": "/anything",
+                    "plugins": {
+                        "ai-proxy": {
+                            "provider": "openai",
+                            "auth": { "header": { "Authorization": "Bearer 
test-key" } },
+                            "options": { "model": "gpt-4o" },
+                            "override": { "endpoint": "http://127.0.0.1:1980"; }
+                        },
+                        "ai-cache": {
+                            "redis_host": "127.0.0.1",
+                            "redis_port": 6379,
+                            "cache_key": { "share_across_routes": true }
+                        }
+                    }
+                }]]
+            )
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+
+            code, body = t('/apisix/admin/routes/2',
+                ngx.HTTP_PUT,
+                [[{
+                    "uri": "/cache-route-b",
+                    "plugins": {
+                        "ai-proxy": {
+                            "provider": "openai",
+                            "auth": { "header": { "Authorization": "Bearer 
test-key" } },
+                            "options": { "model": "gpt-4o" },
+                            "override": { "endpoint": "http://127.0.0.1:1980"; }
+                        },
+                        "ai-cache": {
+                            "redis_host": "127.0.0.1",
+                            "redis_port": 6379,
+                            "cache_key": { "share_across_routes": true }
+                        }
+                    }
+                }]]
+            )
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+--- response_body
+passed
+passed
+
+
+
+=== TEST 40: route 1 cold request is a MISS (warms the shared scope)
+--- request
+POST /anything
+{"model":"gpt-4o","messages":[{"role":"user","content":"cross-route share 
test"}]}
+--- more_headers
+X-AI-Fixture: openai/chat-basic.json
+--- response_headers
+X-AI-Cache-Status: MISS
+--- wait: 0.3
+
+
+
+=== TEST 41: same prompt on route 2 is a HIT (cache shared across routes)
+--- request
+POST /cache-route-b
+{"model":"gpt-4o","messages":[{"role":"user","content":"cross-route share 
test"}]}
+--- error_code: 200
+--- response_headers
+X-AI-Cache-Status: HIT
+
+
+
+=== TEST 42: route with ai-cache but NO ai-proxy in front
+--- 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": "/v1/chat/completions",
+                    "upstream": {
+                        "type": "roundrobin",
+                        "nodes": { "127.0.0.1:1980": 1 }
+                    },
+                    "plugins": {
+                        "ai-cache": {
+                            "redis_host": "127.0.0.1",
+                            "redis_port": 6379
+                        }
+                    }
+                }]]
+            )
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+--- response_body
+passed
+
+
+
+=== TEST 43: a request that never passed through ai-proxy is bypassed, not 
cached
+--- request
+POST /v1/chat/completions
+{"model":"gpt-4o","messages":[{"role":"user","content":"no ai-proxy guard 
test"}]}
+--- more_headers
+X-AI-Fixture: openai/chat-basic.json
+--- error_code: 200
+--- response_headers
+X-AI-Cache-Status: BYPASS
+
+
+
+=== TEST 44: route with ai-cache fail_mode=error and NO ai-proxy
+--- 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": "/v1/chat/completions",
+                    "upstream": {
+                        "type": "roundrobin",
+                        "nodes": { "127.0.0.1:1980": 1 }
+                    },
+                    "plugins": {
+                        "ai-cache": {
+                            "redis_host": "127.0.0.1",
+                            "redis_port": 6379,
+                            "fail_mode": "error"
+                        }
+                    }
+                }]]
+            )
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+--- response_body
+passed
+
+
+
+=== TEST 45: fail_mode=error rejects a request that bypassed the AI proxy
+--- request
+POST /v1/chat/completions
+{"model":"gpt-4o","messages":[{"role":"user","content":"fail_mode error guard 
test"}]}
+--- more_headers
+X-AI-Fixture: openai/chat-basic.json
+--- error_code: 500
+--- response_body_like eval
+qr/must be used with the ai-proxy/
+
+
+
+=== TEST 46: flush redis, then set one ai-proxy-multi route with two instances
+--- extra_yaml_config
+plugins:
+  - ai-proxy-multi
+  - ai-cache
+--- config
+    location /t {
+        content_by_lua_block {
+            require("lib.test_redis").flush_port("127.0.0.1", 6379)
+
+            local t = require("lib.test_admin").test
+            local code, body = t('/apisix/admin/routes/1',
+                ngx.HTTP_PUT,
+                [[{
+                    "uri": "/multi",
+                    "plugins": {
+                        "ai-proxy-multi": {
+                            "instances": [
+                                {
+                                    "name": "instance-gpt4o",
+                                    "provider": "openai",
+                                    "weight": 1,
+                                    "auth": { "header": { "Authorization": 
"Bearer test-key" } },
+                                    "options": { "model": "gpt-4o" },
+                                    "override": { "endpoint": 
"http://127.0.0.1:1980"; }
+                                },
+                                {
+                                    "name": "instance-gpt4o-mini",
+                                    "provider": "openai",
+                                    "weight": 1,
+                                    "auth": { "header": { "Authorization": 
"Bearer test-key" } },
+                                    "options": { "model": "gpt-4o-mini" },
+                                    "override": { "endpoint": 
"http://127.0.0.1:1980"; }
+                                }
+                            ]
+                        },
+                        "ai-cache": {
+                            "redis_host": "127.0.0.1",
+                            "redis_port": 6379
+                        }
+                    }
+                }]]
+            )
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+--- response_body
+passed
+
+
+
+=== TEST 47: round-robin alternates instances, so each one caches independently
+--- extra_yaml_config
+plugins:
+  - ai-proxy-multi
+  - ai-cache
+--- pipelined_requests eval
+[
+    "POST /multi\n" . 
'{"model":"gpt-4o","messages":[{"role":"user","content":"multi-instance 
isolation"}]}',
+    "POST /multi\n" . 
'{"model":"gpt-4o","messages":[{"role":"user","content":"multi-instance 
isolation"}]}',
+    "POST /multi\n" . 
'{"model":"gpt-4o","messages":[{"role":"user","content":"multi-instance 
isolation"}]}',
+    "POST /multi\n" . 
'{"model":"gpt-4o","messages":[{"role":"user","content":"multi-instance 
isolation"}]}',
+]
+--- more_headers
+X-AI-Fixture: openai/chat-basic.json
+--- response_headers eval
+[
+    "X-AI-Cache-Status: MISS",
+    "X-AI-Cache-Status: MISS",
+    "X-AI-Cache-Status: HIT",
+    "X-AI-Cache-Status: HIT",
+]
+
+
+
+=== TEST 48: flush redis, then two plain ai-proxy routes, same provider, 
different
+options.model, shared cache
+--- config
+    location /t {
+        content_by_lua_block {
+            require("lib.test_redis").flush_port("127.0.0.1", 6379)
+
+            local t = require("lib.test_admin").test
+
+            local code, body = t('/apisix/admin/routes/1',
+                ngx.HTTP_PUT,
+                [[{
+                    "uri": "/anything",
+                    "plugins": {
+                        "ai-proxy": {
+                            "provider": "openai",
+                            "auth": { "header": { "Authorization": "Bearer 
test-key" } },
+                            "options": { "model": "gpt-4o" },
+                            "override": { "endpoint": "http://127.0.0.1:1980"; }
+                        },
+                        "ai-cache": {
+                            "redis_host": "127.0.0.1",
+                            "redis_port": 6379,
+                            "cache_key": { "share_across_routes": true }
+                        }
+                    }
+                }]]
+            )
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+
+            code, body = t('/apisix/admin/routes/2',
+                ngx.HTTP_PUT,
+                [[{
+                    "uri": "/cache-route-b",
+                    "plugins": {
+                        "ai-proxy": {
+                            "provider": "openai",
+                            "auth": { "header": { "Authorization": "Bearer 
test-key" } },
+                            "options": { "model": "gpt-4o-mini" },
+                            "override": { "endpoint": "http://127.0.0.1:1980"; }
+                        },
+                        "ai-cache": {
+                            "redis_host": "127.0.0.1",
+                            "redis_port": 6379,
+                            "cache_key": { "share_across_routes": true }
+                        }
+                    }
+                }]]
+            )
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+--- response_body
+passed
+passed
+
+
+
+=== TEST 49: route 1 cold request is a MISS (warms the shared scope under 
gpt-4o)
+--- request
+POST /anything
+{"model":"gpt-4o","messages":[{"role":"user","content":"cross-model share 
test"}]}
+--- more_headers
+X-AI-Fixture: openai/chat-basic.json
+--- response_headers
+X-AI-Cache-Status: MISS
+--- wait: 0.3
+
+
+
+=== TEST 50: identical request on route 2 is a MISS, not a HIT (different 
effective
+model is not shared even with share_across_routes)
+--- request
+POST /cache-route-b
+{"model":"gpt-4o","messages":[{"role":"user","content":"cross-model share 
test"}]}
+--- more_headers
+X-AI-Fixture: openai/chat-basic.json
+--- response_headers
+X-AI-Cache-Status: MISS
+
+
+
+=== TEST 51: flush redis, then two plain ai-proxy routes, same provider and 
same
+options.model, but different server-side options (temperature), shared cache
+--- config
+    location /t {
+        content_by_lua_block {
+            require("lib.test_redis").flush_port("127.0.0.1", 6379)
+
+            local t = require("lib.test_admin").test
+
+            local code, body = t('/apisix/admin/routes/1',
+                ngx.HTTP_PUT,
+                [[{
+                    "uri": "/anything",
+                    "plugins": {
+                        "ai-proxy": {
+                            "provider": "openai",
+                            "auth": { "header": { "Authorization": "Bearer 
test-key" } },
+                            "options": { "model": "gpt-4o", "temperature": 0.2 
},
+                            "override": { "endpoint": "http://127.0.0.1:1980"; }
+                        },
+                        "ai-cache": {
+                            "redis_host": "127.0.0.1",
+                            "redis_port": 6379,
+                            "cache_key": { "share_across_routes": true }
+                        }
+                    }
+                }]]
+            )
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+
+            code, body = t('/apisix/admin/routes/2',
+                ngx.HTTP_PUT,
+                [[{
+                    "uri": "/cache-route-b",
+                    "plugins": {
+                        "ai-proxy": {
+                            "provider": "openai",
+                            "auth": { "header": { "Authorization": "Bearer 
test-key" } },
+                            "options": { "model": "gpt-4o", "temperature": 0.8 
},
+                            "override": { "endpoint": "http://127.0.0.1:1980"; }
+                        },
+                        "ai-cache": {
+                            "redis_host": "127.0.0.1",
+                            "redis_port": 6379,
+                            "cache_key": { "share_across_routes": true }
+                        }
+                    }
+                }]]
+            )
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+--- response_body
+passed
+passed
+
+
+
+=== TEST 52: route 1 cold request is a MISS (warms the shared scope under 
temperature=0.2)
+--- request
+POST /anything
+{"model":"gpt-4o","messages":[{"role":"user","content":"cross-options share 
test"}]}
+--- more_headers
+X-AI-Fixture: openai/chat-basic.json
+--- response_headers
+X-AI-Cache-Status: MISS
+--- wait: 0.3
+
+
+
+=== TEST 53: identical client body on route 2 is a MISS, not a HIT (different
+server-side options are not shared even with share_across_routes)
+--- request
+POST /cache-route-b
+{"model":"gpt-4o","messages":[{"role":"user","content":"cross-options share 
test"}]}
+--- more_headers
+X-AI-Fixture: openai/chat-basic.json
+--- response_headers
+X-AI-Cache-Status: MISS
+
+
+
+=== TEST 54: ai-proxy-multi route whose higher-priority instance always 5xxs 
and
+falls back to a healthy instance (different effective endpoint)
+--- extra_yaml_config
+plugins:
+  - ai-proxy-multi
+  - ai-cache
+--- config
+    location /t {
+        content_by_lua_block {
+            require("lib.test_redis").flush_port("127.0.0.1", 6379)
+
+            local t = require("lib.test_admin").test
+            local code, body = t('/apisix/admin/routes/1',
+                ngx.HTTP_PUT,
+                [[{
+                    "uri": "/fallback",
+                    "plugins": {
+                        "ai-proxy-multi": {
+                            "fallback_strategy": ["http_5xx"],
+                            "balancer": { "algorithm": "roundrobin" },
+                            "instances": [
+                                {
+                                    "name": "primary-fail",
+                                    "provider": "openai",
+                                    "weight": 1,
+                                    "priority": 2,
+                                    "auth": { "header": { "Authorization": 
"Bearer test-key" } },
+                                    "options": { "model": "gpt-4o" },
+                                    "override": { "endpoint": 
"http://127.0.0.1:6731"; }
+                                },
+                                {
+                                    "name": "backup-ok",
+                                    "provider": "openai",
+                                    "weight": 1,
+                                    "priority": 1,
+                                    "auth": { "header": { "Authorization": 
"Bearer test-key" } },
+                                    "options": { "model": "gpt-4o" },
+                                    "override": { "endpoint": 
"http://127.0.0.1:1980"; }
+                                }
+                            ]
+                        },
+                        "ai-cache": {
+                            "redis_host": "127.0.0.1",
+                            "redis_port": 6379
+                        }
+                    }
+                }]]
+            )
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+--- response_body
+passed
+
+
+
+=== TEST 55: first request falls back to the healthy instance and is a MISS
+--- extra_yaml_config
+plugins:
+  - ai-proxy-multi
+  - ai-cache
+--- request
+POST /fallback
+{"model":"gpt-4o","messages":[{"role":"user","content":"fallback poison 
test"}]}
+--- more_headers
+X-AI-Fixture: openai/chat-basic.json
+--- error_code: 200
+--- response_headers
+X-AI-Cache-Status: MISS
+--- wait: 0.3
+
+
+
+=== TEST 56: identical request is STILL a MISS -- the fallback instance's 
response
+must not be cached under the originally-picked instance's key
+--- extra_yaml_config
+plugins:
+  - ai-proxy-multi
+  - ai-cache
+--- request
+POST /fallback
+{"model":"gpt-4o","messages":[{"role":"user","content":"fallback poison 
test"}]}
+--- more_headers
+X-AI-Fixture: openai/chat-basic.json
+--- error_code: 200
+--- response_headers
+X-AI-Cache-Status: MISS
+
+
+
+=== TEST 57: scope() escapes include_vars values so a tenant cannot forge 
another's scope (key.lua unit)
+--- config
+    location /t {
+        content_by_lua_block {
+            local key = require("apisix.plugins.ai-cache.key")
+
+            -- include_vars values are request-controlled (e.g. headers), so 
they
+            -- must not be able to inject the ":"/"=" scope separators and 
shift
+            -- field boundaries into another tenant's scope.
+            local conf = { cache_key = { share_across_routes = true,
+                                         include_vars = { "http_x_a", 
"http_x_b" } } }
+            local function key_for(a, b)
+                return key.build(conf, { var = { http_x_a = a, http_x_b = b } 
}, "fp")
+            end
+
+            -- baseline: identical values share a key, and plain values stay 
greppable
+            assert(key_for("acme", "us") == key_for("acme", "us"),
+                   "identical scope values must produce the same key")
+            assert(key_for("acme", "us"):find("http_x_a=acme", 1, true),
+                   "plain values must stay readable in the key")
+
+            -- two tenants whose raw "http_x_a=<a>:http_x_b=<b>" join both 
collapse
+            -- to "http_x_a=x:http_x_b=yZ:http_x_b=" unless the values are 
escaped
+            local tenant1 = key_for("x:http_x_b=yZ", "")
+            local tenant2 = key_for("x", "yZ:http_x_b=")
+            assert(tenant1 ~= tenant2,
+                   "separator-injecting values must not collide into one 
scope")
+            ngx.say("passed")
+        }
+    }
+--- response_body
+passed


Reply via email to