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 7fd4515ee feat(ai-cache): add semantic (L2) cache layer (#13632)
7fd4515ee is described below
commit 7fd4515ee419de72b8877ef4e8c1c2027b59a2b4
Author: Mohammad Izzraff Janius
<[email protected]>
AuthorDate: Thu Jul 2 12:28:15 2026 +0800
feat(ai-cache): add semantic (L2) cache layer (#13632)
---
Makefile | 4 +
apisix/plugins/ai-cache.lua | 119 +-
.../plugins/ai-cache/embeddings/azure_openai.lua | 39 +
apisix/plugins/ai-cache/embeddings/base.lua | 59 +
apisix/plugins/ai-cache/embeddings/openai.lua | 41 +
apisix/plugins/ai-cache/key.lua | 62 +-
apisix/plugins/ai-cache/schema.lua | 85 +-
apisix/plugins/ai-cache/semantic.lua | 324 +++++
apisix/plugins/ai-cache/vector-search/redis.lua | 133 ++
docs/en/latest/plugins/ai-cache.md | 185 ++-
docs/zh/latest/plugins/ai-cache.md | 185 ++-
t/fixtures/openai/embeddings-capital-city.json | 80 +
t/fixtures/openai/embeddings-capital.json | 80 +
t/fixtures/openai/embeddings-largest-city.json | 80 +
t/fixtures/openai/embeddings-tire.json | 80 +
t/lib/ai_cache_mock.lua | 117 ++
t/plugin/ai-cache-semantic.t | 1527 ++++++++++++++++++++
17 files changed, 3167 insertions(+), 33 deletions(-)
diff --git a/Makefile b/Makefile
index 733f62d20..667a0774e 100644
--- a/Makefile
+++ b/Makefile
@@ -403,6 +403,10 @@ install: runtime
$(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-cache/embeddings
+ $(ENV_INSTALL) apisix/plugins/ai-cache/embeddings/*.lua
$(ENV_INST_LUADIR)/apisix/plugins/ai-cache/embeddings
+ $(ENV_INSTALL) -d
$(ENV_INST_LUADIR)/apisix/plugins/ai-cache/vector-search
+ $(ENV_INSTALL) apisix/plugins/ai-cache/vector-search/*.lua
$(ENV_INST_LUADIR)/apisix/plugins/ai-cache/vector-search
$(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/plugins/ai-cache.lua b/apisix/plugins/ai-cache.lua
index ba6f38b43..895ccc7c5 100644
--- a/apisix/plugins/ai-cache.lua
+++ b/apisix/plugins/ai-cache.lua
@@ -20,16 +20,21 @@ 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 semantic = require("apisix.plugins.ai-cache.semantic")
local ngx = ngx
local ngx_null = ngx.null
local ipairs = ipairs
+local pcall = pcall
local concat = table.concat
+local str_format = string.format
-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 CACHE_STATUS_HEADER = "X-AI-Cache-Status"
+local CACHE_AGE_HEADER = "X-AI-Cache-Age"
+local CACHE_SIMILARITY_HEADER = "X-AI-Cache-Similarity"
+local DEFAULT_TTL = 3600
+local DEFAULT_MAX_BODY = 1048576
+local DEFAULT_SEMANTIC_TTL = 86400
local _M = {
version = 0.1,
@@ -39,8 +44,31 @@ local _M = {
}
+local function has_layer(conf, name)
+ local layers = conf.layers or { "exact" }
+ for _, l in ipairs(layers) do
+ if l == name then
+ return true
+ end
+ end
+ return false
+end
+
+
function _M.check_schema(conf)
- return core.schema.check(schema, conf)
+ core.utils.check_https({
+ "semantic.embedding.openai.endpoint",
+ "semantic.embedding.azure_openai.endpoint",
+ }, conf, _M.name)
+ local ok, err = core.schema.check(schema, conf)
+ if not ok then
+ return false, err
+ end
+ if conf.semantic and not has_layer(conf, "semantic") then
+ core.log.warn("ai-cache: 'semantic' is configured but not listed in ",
+ "'layers'; the semantic (L2) cache is inactive")
+ end
+ return true
end
@@ -53,12 +81,17 @@ local function release(conf, red)
end
-local function serve_hit(conf, ctx, cached)
- ctx.ai_cache_status = "HIT"
+local function serve_hit(conf, ctx, cached, similarity)
+ local status = "HIT"
+ ctx.ai_cache_status = status
if conf.cache_headers ~= false then
- core.response.set_header(CACHE_STATUS_HEADER, "HIT")
+ core.response.set_header(CACHE_STATUS_HEADER, status)
local age = ngx.time() - (cached.created_at or ngx.time())
core.response.set_header(CACHE_AGE_HEADER, age < 0 and 0 or age)
+ if similarity then
+ core.response.set_header(CACHE_SIMILARITY_HEADER,
+ str_format("%.4f", similarity))
+ end
end
core.response.set_header("Content-Type", "application/json")
return core.response.exit(200, cached.body)
@@ -126,16 +159,56 @@ function _M.access(conf, ctx)
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
+ release(conf, red)
return serve_hit(conf, ctx, cached)
end
core.log.warn("ai-cache: discarding malformed cache entry for ",
ctx.ai_cache_key)
end
+ -- L1 miss -> L2 semantic lookup. Release the L1 connection before
+ -- embed_query()'s HTTP call so the pool isn't pinned across the embedding
+ -- round-trip; re-acquire for the vector search. pcall keeps throws
fail-open.
+ if has_layer(conf, "semantic") and conf.semantic then
+ release(conf, red)
+
+ local ok, vec = pcall(semantic.embed_query, conf, ctx, body)
+ if not ok then
+ core.log.warn("ai-cache: semantic embed error, fail-open as MISS:
", vec)
+ vec = nil
+ -- prevent log() from scheduling a write with partial/bad state
+ ctx.ai_cache_embedding = nil
+ end
+
+ if vec then
+ local sred
+ sred, err = redis_util.new(conf)
+ if not sred then
+ core.log.warn("ai-cache: redis unavailable for semantic
search, ",
+ "fail-open as MISS: ", err)
+ else
+ local sok, hit = pcall(semantic.search, sred, conf, ctx, vec)
+ if not sok then
+ sred:close()
+ core.log.warn("ai-cache: semantic search error, fail-open
as MISS: ", hit)
+ ctx.ai_cache_embedding = nil
+ else
+ release(conf, sred)
+ if hit then
+ return serve_hit(conf, ctx,
+ { body = hit.body, created_at = hit.created_at },
hit.similarity)
+ end
+ end
+ end
+ end
+
+ ctx.ai_cache_status = "MISS"
+ return
+ end
+
+ release(conf, red)
ctx.ai_cache_status = "MISS"
end
@@ -174,7 +247,8 @@ 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)
+-- l2 (optional) = { partition, embedding, dim, fingerprint, ttl } for L2
write.
+local function write_to_cache(premature, conf, cache_key, response_body, l2)
if premature then
return
end
@@ -192,6 +266,13 @@ local function write_to_cache(premature, conf, cache_key,
response_body)
core.log.warn("ai-cache: redis set failed: ", err)
return
end
+ if l2 then
+ l2.created_at = ngx.time()
+ local wok, werr = pcall(semantic.write, red, conf, l2, response_body)
+ if not wok then
+ core.log.warn("ai-cache: semantic write error: ", werr)
+ end
+ end
release(conf, red)
end
@@ -218,7 +299,21 @@ function _M.log(conf, ctx)
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)
+
+ -- Build the L2 doc from ctx fields stashed by semantic.embed_query(); the
+ -- embedding is only set on a successful embed, so a nil check guards the
write.
+ local l2
+ if has_layer(conf, "semantic") and ctx.ai_cache_embedding then
+ l2 = {
+ partition = ctx.ai_cache_partition,
+ embedding = ctx.ai_cache_embedding,
+ dim = ctx.ai_cache_dim,
+ fingerprint = ctx.ai_cache_fingerprint,
+ ttl = (conf.semantic and conf.semantic.ttl) or
DEFAULT_SEMANTIC_TTL,
+ }
+ end
+
+ local ok, err = ngx.timer.at(0, write_to_cache, conf, cache_key,
response_body, l2)
if not ok then
core.log.warn("ai-cache: failed to schedule cache write: ", err)
end
diff --git a/apisix/plugins/ai-cache/embeddings/azure_openai.lua
b/apisix/plugins/ai-cache/embeddings/azure_openai.lua
new file mode 100644
index 000000000..7421a6a72
--- /dev/null
+++ b/apisix/plugins/ai-cache/embeddings/azure_openai.lua
@@ -0,0 +1,39 @@
+--
+-- 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 base = require("apisix.plugins.ai-cache.embeddings.base")
+
+local _M = {}
+
+-- get_embeddings(conf, text, httpc, ssl_verify) -> (vector_table, err)
+function _M.get_embeddings(conf, text, httpc, ssl_verify)
+ local req = { input = text }
+ if conf.dimensions then
+ req.dimensions = conf.dimensions
+ end
+ return base.fetch({
+ endpoint = conf.endpoint,
+ headers = {
+ ["Content-Type"] = "application/json",
+ ["api-key"] = conf.api_key,
+ },
+ request = req,
+ httpc = httpc,
+ ssl_verify = ssl_verify,
+ })
+end
+
+return _M
diff --git a/apisix/plugins/ai-cache/embeddings/base.lua
b/apisix/plugins/ai-cache/embeddings/base.lua
new file mode 100644
index 000000000..dda5f1ee2
--- /dev/null
+++ b/apisix/plugins/ai-cache/embeddings/base.lua
@@ -0,0 +1,59 @@
+--
+-- 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.
+--
+
+-- Shared request/parse core for the embeddings drivers. Each provider differs
+-- only in its request shape, auth header, and default endpoint; the HTTP call,
+-- status check, and data[1].embedding extraction live here so a parsing fix
+-- lands in one place.
+local core = require("apisix.core")
+local type = type
+
+local HTTP_OK = ngx.HTTP_OK
+
+local _M = {}
+
+-- fetch(opts) where opts = { endpoint, headers, request, httpc, ssl_verify }
+-- -> (vector_table, err)
+function _M.fetch(opts)
+ local payload, err = core.json.encode(opts.request)
+ if not payload then
+ return nil, "encode embeddings request: " .. (err or "")
+ end
+
+ local res
+ res, err = opts.httpc:request_uri(opts.endpoint, {
+ method = "POST",
+ headers = opts.headers,
+ body = payload,
+ ssl_verify = opts.ssl_verify,
+ })
+ if not res or not res.body then
+ return nil, "embeddings request failed: " .. (err or "")
+ end
+ if res.status ~= HTTP_OK then
+ return nil, "embeddings endpoint returned " .. res.status
+ end
+
+ local decoded = core.json.decode(res.body)
+ if not decoded or type(decoded.data) ~= "table" or type(decoded.data[1])
~= "table"
+ or type(decoded.data[1].embedding) ~= "table" then
+ return nil, "malformed embeddings response"
+ end
+ return decoded.data[1].embedding
+end
+
+return _M
diff --git a/apisix/plugins/ai-cache/embeddings/openai.lua
b/apisix/plugins/ai-cache/embeddings/openai.lua
new file mode 100644
index 000000000..43e123aa5
--- /dev/null
+++ b/apisix/plugins/ai-cache/embeddings/openai.lua
@@ -0,0 +1,41 @@
+--
+-- 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 base = require("apisix.plugins.ai-cache.embeddings.base")
+
+local _M = {}
+
+_M.DEFAULT_ENDPOINT = "https://api.openai.com/v1/embeddings"
+
+-- get_embeddings(conf, text, httpc, ssl_verify) -> (vector_table, err)
+function _M.get_embeddings(conf, text, httpc, ssl_verify)
+ local req = { model = conf.model, input = text }
+ if conf.dimensions then
+ req.dimensions = conf.dimensions
+ end
+ return base.fetch({
+ endpoint = conf.endpoint or _M.DEFAULT_ENDPOINT,
+ headers = {
+ ["Content-Type"] = "application/json",
+ ["Authorization"] = "Bearer " .. conf.api_key,
+ },
+ request = req,
+ httpc = httpc,
+ ssl_verify = ssl_verify,
+ })
+end
+
+return _M
diff --git a/apisix/plugins/ai-cache/key.lua b/apisix/plugins/ai-cache/key.lua
index 3f5d408ef..bd09cc486 100644
--- a/apisix/plugins/ai-cache/key.lua
+++ b/apisix/plugins/ai-cache/key.lua
@@ -46,17 +46,17 @@ local function client_messages(ctx, body)
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)
+function _M.messages(ctx, body)
+ return client_messages(ctx, body)
+end
+
+
+-- Build the canonical representable struct. `messages` is the message list
+-- folded into the representation: the full client messages for the exact (L1)
+-- fingerprint, or only the response-determining context the embedding does not
+-- cover (system prompts, prior turns, RAG documents) for the semantic (L2)
+-- partition. nil omits message text entirely.
+local function build_repr(ctx, body, messages)
local inst = ctx.picked_ai_instance
local ov = inst.override or {}
@@ -67,14 +67,14 @@ function _M.fingerprint(ctx, body)
end
end
- local repr = core.json.canonical_encode({
+ return {
client = {
protocol = ctx.ai_client_protocol or "",
- messages = client_messages(ctx, body),
+ messages = messages,
params = params,
},
effective = {
- provider = inst.provider,
+ 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 "",
@@ -87,8 +87,32 @@ function _M.fingerprint(ctx, body)
-- ARN, vertex project/region/model), so it is
response-determining.
endpoint = ov.endpoint,
},
- })
- return hex_digest(repr)
+ }
+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 repr = build_repr(ctx, body, client_messages(ctx, body))
+ return hex_digest(core.json.canonical_encode(repr))
+end
+
+
+-- Returns the SHA-256 hex digest of the effective context with message text
+-- removed. Queries that differ only in phrasing (same model/params/instance)
+-- share one fingerprint, enabling semantic deduplication without storing the
+-- raw prompt.
+function _M.context_fingerprint(ctx, body)
+ return hex_digest(core.json.canonical_encode(build_repr(ctx, body, nil)))
end
@@ -122,6 +146,12 @@ local function scope(conf, ctx)
end
+function _M.partition(conf, ctx, body, context_messages)
+ local context_repr = core.json.canonical_encode(build_repr(ctx, body,
context_messages))
+ return hex_digest(scope(conf, ctx) .. "|" .. context_repr)
+end
+
+
function _M.build(conf, ctx, fingerprint)
return KEY_PREFIX .. scope(conf, ctx) .. ":" .. fingerprint
end
diff --git a/apisix/plugins/ai-cache/schema.lua
b/apisix/plugins/ai-cache/schema.lua
index 5eb62661c..9be74c97f 100644
--- a/apisix/plugins/ai-cache/schema.lua
+++ b/apisix/plugins/ai-cache/schema.lua
@@ -74,6 +74,78 @@ local _M = {
enum = { "redis" },
default = "redis",
},
+
+ layers = {
+ type = "array",
+ items = { enum = { "exact", "semantic" } },
+ minItems = 1,
+ uniqueItems = true,
+ contains = { const = "exact" },
+ default = { "exact" },
+ },
+
+ semantic = {
+ type = "object",
+ properties = {
+ similarity_threshold = {
+ type = "number", minimum = 0, maximum = 1, default = 0.95,
+ },
+ top_k = { type = "integer", minimum = 1, default = 1 },
+ distance_metric = { enum = { "cosine" }, default = "cosine" },
+ ttl = { type = "integer", minimum = 1, default = 86400 },
+ match = {
+ type = "object",
+ properties = {
+ message_countback = { type = "integer", minimum = 1,
default = 1 },
+ ignore_system_prompts = { type = "boolean", default =
true },
+ ignore_assistant_prompts = { type = "boolean", default
= true },
+ ignore_tool_prompts = { type = "boolean", default =
true },
+ },
+ default = {},
+ },
+ embedding = {
+ type = "object",
+ properties = {
+ openai = {
+ type = "object",
+ properties = {
+ endpoint = { type = "string" },
+ model = { type = "string" },
+ api_key = { type = "string" },
+ dimensions = { type = "integer", minimum = 1 },
+ ssl_verify = { type = "boolean", default =
true },
+ timeout = { type = "integer", minimum = 1,
default = 5000 },
+ },
+ required = { "model", "api_key" },
+ },
+ azure_openai = {
+ type = "object",
+ properties = {
+ endpoint = { type = "string" },
+ api_key = { type = "string" },
+ dimensions = { type = "integer", minimum = 1 },
+ ssl_verify = { type = "boolean", default =
true },
+ timeout = { type = "integer", minimum = 1,
default = 5000 },
+ },
+ required = { "endpoint", "api_key" },
+ },
+ },
+ oneOf = { { required = { "openai" } }, { required = {
"azure_openai" } } },
+ },
+ vector_search = {
+ type = "object",
+ properties = {
+ redis = {
+ type = "object",
+ properties = { index = { type = "string", default
= "ai-cache" } },
+ default = {},
+ },
+ },
+ required = { "redis" },
+ },
+ },
+ required = { "embedding", "vector_search" },
+ },
},
["if"] = {
properties = {
@@ -83,7 +155,18 @@ local _M = {
},
},
["then"] = policy_to_additional_properties.redis,
- encrypt_fields = { "redis_password" },
+ allOf = {
+ {
+ ["if"] = { properties = { layers = { contains = { const =
"semantic" } } },
+ required = { "layers" } },
+ ["then"] = { required = { "semantic" } },
+ },
+ },
+ encrypt_fields = {
+ "redis_password",
+ "semantic.embedding.openai.api_key",
+ "semantic.embedding.azure_openai.api_key",
+ },
}
return _M
diff --git a/apisix/plugins/ai-cache/semantic.lua
b/apisix/plugins/ai-cache/semantic.lua
new file mode 100644
index 000000000..4c603a9dc
--- /dev/null
+++ b/apisix/plugins/ai-cache/semantic.lua
@@ -0,0 +1,324 @@
+--
+-- 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 http = require("resty.http")
+local sha256 = require("resty.sha256")
+local to_hex = require("resty.string").to_hex
+local key_mod = require("apisix.plugins.ai-cache.key")
+local vs = require("apisix.plugins.ai-cache.vector-search.redis")
+
+local ipairs = ipairs
+local type = type
+local concat = table.concat
+local tostring = tostring
+
+-- Pre-require both drivers so a misconfigured provider name cannot escape
+-- lookup()'s fail-open boundary via a request-time require() raise.
+local drivers = {
+ openai = require("apisix.plugins.ai-cache.embeddings.openai"),
+ azure_openai = require("apisix.plugins.ai-cache.embeddings.azure_openai"),
+}
+
+local _M = {}
+
+local DEFAULT_THRESHOLD = 0.95
+local DEFAULT_TOP_K = 1
+
+-- Semantic L2 reconstructs the prompt through the client protocol's
+-- get_messages(): the last turn becomes the embedded query and the rest
becomes
+-- the partition context. That is only sound when get_messages() is a faithful,
+-- lossless view of the prompt. Today only openai-chat qualifies -- it returns
+-- body.messages verbatim -- whereas the other protocols drop all non-text
+-- content (images, tool calls, structured input), so two distinct prompts
could
+-- canonicalise to the same messages and collide on one cache cell. L2
therefore
+-- engages only for these protocols and bypasses (exact L1 still applies) for
the
+-- rest, rather than risk a cross-prompt false hit.
+local SEMANTIC_PROTOCOLS = {
+ ["openai-chat"] = true,
+}
+
+
+local function text_of(content)
+ if type(content) == "string" then
+ return content
+ end
+ if type(content) == "table" then
+ local parts = {}
+ for _, block in ipairs(content) do
+ if type(block) == "table" and block.type == "text" and block.text
then
+ parts[#parts + 1] = block.text
+ end
+ end
+ return concat(parts, "\n")
+ end
+ return ""
+end
+
+
+-- Indices (into `messages`) of the messages whose text is embedded: the last
+-- `message_countback` messages surviving the role filters. Shared by
+-- extract_embed_text (which embeds them) and context_messages (which excludes
+-- them from the L2 partition) so the two can never disagree on the split.
+local function embed_window(messages, match)
+ local m = match or {}
+ local kept = {}
+ for i, msg in ipairs(messages) do
+ local role = msg.role
+ local skip = (role == "system" and m.ignore_system_prompts ~= false)
+ or (role == "assistant" and m.ignore_assistant_prompts ~=
false)
+ or (role == "tool" and m.ignore_tool_prompts ~= false)
+ if not skip then
+ kept[#kept + 1] = i
+ end
+ end
+ local countback = m.message_countback or 1
+ local start = #kept - countback + 1
+ if start < 1 then start = 1 end
+ local window = {}
+ for w = start, #kept do
+ window[#window + 1] = kept[w]
+ end
+ return window
+end
+
+
+function _M.extract_embed_text(messages, match)
+ local texts = {}
+ for _, i in ipairs(embed_window(messages, match)) do
+ local t = text_of(messages[i].content)
+ if t ~= "" then
+ texts[#texts + 1] = t
+ end
+ end
+ return concat(texts, "\n")
+end
+
+
+-- The messages NOT in the embed window: response-determining context (system
+-- prompts, prior turns, RAG documents) that the embedding ignores. The
semantic
+-- layer folds these into the L2 partition so a generic instruction over
+-- different context never collides on another context's cached response.
+function _M.context_messages(messages, match)
+ local in_window = {}
+ for _, i in ipairs(embed_window(messages, match)) do
+ in_window[i] = true
+ end
+ local context = {}
+ for i, msg in ipairs(messages) do
+ if not in_window[i] then
+ context[#context + 1] = msg
+ end
+ end
+ return context
+end
+
+
+function _M.window_has_nontext(messages, match)
+ for _, i in ipairs(embed_window(messages, match)) do
+ local content = messages[i].content
+ if type(content) == "table" then
+ for _, block in ipairs(content) do
+ if type(block) == "table" and block.type and block.type ~=
"text" then
+ return true
+ end
+ end
+ end
+ end
+ return false
+end
+
+
+-- Base name for this plugin instance's RediSearch index and L2 key prefix.
+-- The schema requires vector_search and defaults redis.index to "ai-cache",
+-- so the validated conf always carries the value.
+local function l2_base(conf)
+ return conf.semantic.vector_search.redis.index
+end
+
+
+-- Short, stable fingerprint of the EMBEDDING model space (provider + model +
+-- endpoint + dimensions). Cosine distance is only meaningful between vectors
+-- from the same model, so this is folded into the index name and key prefix:
+-- changing the embedding model (even to one of the same dimensionality) lands
+-- in a fresh, isolated index instead of being compared against stale vectors
+-- from the previous model.
+local function emb_identity(conf)
+ local emb = conf.semantic.embedding
+ local provider = emb.openai and "openai" or "azure_openai"
+ local c = emb[provider]
+ local endpoint = c.endpoint
+ if provider == "openai" then
+ endpoint = endpoint or drivers.openai.DEFAULT_ENDPOINT
+ end
+ local repr = provider .. "|" .. (c.model or "") .. "|" .. (endpoint or "")
+ .. "|" .. tostring(c.dimensions or "")
+ local h = sha256:new()
+ h:update(repr)
+ return to_hex(h:final()):sub(1, 16)
+end
+
+
+function _M.index_name(conf, dim)
+ return l2_base(conf) .. ":idx:" .. emb_identity(conf) .. ":" .. dim
+end
+
+
+-- HASH key prefix the index is built over. Scoped by embedding identity AND
+-- dimension so two indexes (different model or different dim) never share
docs.
+local function l2_prefix(conf, dim)
+ return l2_base(conf) .. ":l2:" .. emb_identity(conf) .. ":" .. dim .. ":"
+end
+
+
+-- host#port#db identity, folded into the FT.CREATE memo key so the same index
+-- name against different Redis targets is created on each (see redis.lua).
+local function redis_target(conf)
+ return (conf.redis_host or "") .. "#" .. (conf.redis_port or 6379)
+ .. "#" .. (conf.redis_database or 0)
+end
+
+
+-- conf.semantic.embedding is a one-key sub-object {openai=..|azure_openai=..}
+local function embed(conf, text)
+ local emb = conf.semantic.embedding
+ local provider = emb.openai and "openai" or "azure_openai"
+ local driver = drivers[provider]
+ local pconf = emb[provider]
+ local httpc = http.new()
+ -- Bound the synchronous embedding call so a slow/hung provider cannot
stall
+ -- the request for the resty default (~60s) before fail-open kicks in.
+ local t = pconf.timeout or 5000
+ httpc:set_timeouts(t, t, t)
+ return driver.get_embeddings(pconf, text, httpc, pconf.ssl_verify ~= false)
+end
+
+
+-- Phase 1 of the L2 lookup: gates + embedding. Does no Redis work -- embed()
is
+-- an HTTP call the caller must not pin a pooled connection across. Returns the
+-- query vector (stashing the write-back ctx fields) or nil to fail open as
MISS.
+function _M.embed_query(conf, ctx, body)
+ -- Bypass L2 (exact L1 still applies) for protocols whose canonical message
+ -- form cannot faithfully represent the prompt; never a cross-prompt hit.
+ if not SEMANTIC_PROTOCOLS[ctx.ai_client_protocol or ""] then
+ return nil
+ end
+ local sem = conf.semantic
+ local messages = key_mod.messages(ctx, body)
+ -- Bypass L2 when an embedded message carries non-text content (images,
etc.):
+ -- it is absent from both the vector and the partition, so a same-text
+ -- different-image prompt would otherwise collide on one L2 cell.
+ if _M.window_has_nontext(messages, sem.match) then
+ return nil
+ end
+ local text = _M.extract_embed_text(messages, sem.match)
+ if text == "" then
+ return nil
+ end
+
+ local vec, err = embed(conf, text)
+ if not vec then
+ core.log.warn("ai-cache: embedding failed, fail-open as MISS: ", err)
+ return nil
+ end
+ -- stash for the write-back in log() (only set when embedding succeeded).
+ -- context = the response-determining messages the embedding ignores;
folding
+ -- them into the partition isolates this prompt from other contexts. nil
for a
+ -- plain single-turn prompt, which keeps the partition identical to before.
+ local ctxmsgs = _M.context_messages(messages, sem.match)
+ local part_ctx = #ctxmsgs > 0 and ctxmsgs or nil
+ ctx.ai_cache_embedding = vec
+ ctx.ai_cache_dim = #vec
+ ctx.ai_cache_partition = key_mod.partition(conf, ctx, body, part_ctx)
+ return vec
+end
+
+
+-- Phase 2 of the L2 lookup: vector search over a caller-owned connection
+-- acquired AFTER embed_query() (so the pool isn't pinned across embedding).
+-- Returns a hit {body, created_at, similarity} on a >=threshold match, else
nil.
+function _M.search(red, conf, ctx, vec)
+ local sem = conf.semantic
+ local target = redis_target(conf)
+ local index = _M.index_name(conf, #vec)
+ local ok, err = vs.ensure_index(red, target, index, l2_prefix(conf, #vec),
#vec)
+ if not ok then
+ core.log.warn("ai-cache: ensure_index failed, fail-open as MISS: ",
err)
+ return nil
+ end
+
+ local hit
+ hit, err = vs.knn_search(red, target, index, ctx.ai_cache_partition, vec,
+ sem.top_k or DEFAULT_TOP_K)
+ if err then
+ core.log.warn("ai-cache: knn search failed, fail-open as MISS: ", err)
+ return nil
+ end
+ if not hit then
+ return nil
+ end
+
+ local similarity = 1 - hit.distance
+ if similarity < (sem.similarity_threshold or DEFAULT_THRESHOLD) then
+ return nil
+ end
+
+ -- L2 -> L1 backfill, carrying the L2 entry's original created_at so Age is
+ -- consistent whether the next hit is served from L1 or L2. A real
semantic
+ -- hit must be served regardless — only the backfill SET is skipped on
error.
+ local envelope = core.json.encode({ body = hit.response, created_at =
hit.created_at })
+ if not envelope then
+ core.log.warn("ai-cache: L1 backfill skipped: json.encode returned
nil")
+ else
+ local exact_ttl = (conf.exact and conf.exact.ttl) or 3600
+ local bok, berr = red:set(ctx.ai_cache_key, envelope, "EX", exact_ttl)
+ if not bok then
+ core.log.warn("ai-cache: L1 backfill SET failed: ", berr)
+ end
+ end
+
+ return { body = hit.response, created_at = hit.created_at, similarity =
similarity }
+end
+
+
+-- Called from the write-back timer (after the L1 SET) with a still-open `red`.
+-- l2 = { partition, embedding, dim, fingerprint, ttl, created_at }
+function _M.write(red, conf, l2, response_body)
+ if not l2 or not l2.embedding then
+ return
+ end
+ local target = redis_target(conf)
+ local index = _M.index_name(conf, l2.dim)
+ local prefix = l2_prefix(conf, l2.dim)
+ local ok, err = vs.ensure_index(red, target, index, prefix, l2.dim)
+ if not ok then
+ core.log.warn("ai-cache: ensure_index on write failed: ", err)
+ return
+ end
+ local doc_key = prefix .. l2.partition .. ":" .. l2.fingerprint
+ ok, err = vs.upsert(red, doc_key, {
+ partition = l2.partition,
+ embedding = vs.pack_float32(l2.embedding),
+ response = response_body,
+ created_at = l2.created_at,
+ }, l2.ttl)
+ if not ok then
+ core.log.warn("ai-cache: L2 upsert failed: ", err)
+ end
+end
+
+
+return _M
diff --git a/apisix/plugins/ai-cache/vector-search/redis.lua
b/apisix/plugins/ai-cache/vector-search/redis.lua
new file mode 100644
index 000000000..9b2adeaf7
--- /dev/null
+++ b/apisix/plugins/ai-cache/vector-search/redis.lua
@@ -0,0 +1,133 @@
+--
+-- 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 ffi = require("ffi")
+local ffi_new = ffi.new
+local ffi_str = ffi.string
+local tonumber = tonumber
+local ipairs = ipairs
+local type = type
+
+local _M = {}
+
+local ensured = {}
+
+
+-- Memo keyed by Redis target (host#port#db) + index, not index alone: the same
+-- index name against different Redis servers must be created on each.
+local function memo_key(target, index)
+ return target .. "|" .. index
+end
+
+
+-- little-endian FLOAT32 blob (RediSearch VECTOR PARAMS / HSET value)
+function _M.pack_float32(vec)
+ local n = #vec
+ local buf = ffi_new("float[?]", n)
+ for i = 1, n do
+ buf[i - 1] = vec[i]
+ end
+ return ffi_str(buf, n * 4)
+end
+
+
+function _M.ensure_index(red, target, index, prefix, dim)
+ local mk = memo_key(target, index)
+ if ensured[mk] then
+ return true
+ end
+ local ok, err = red[ "FT.CREATE" ](red, index,
+ "ON", "HASH", "PREFIX", 1, prefix,
+ "SCHEMA",
+ "partition", "TAG",
+ "embedding", "VECTOR", "HNSW", 6,
+ "TYPE", "FLOAT32", "DIM", dim, "DISTANCE_METRIC", "COSINE")
+ if not ok then
+ -- FT.CREATE on an existing index returns this error; treat as success.
+ if err and err:find("Index already exists", 1, true) then
+ ensured[mk] = true
+ return true
+ end
+ return nil, err
+ end
+ ensured[mk] = true
+ return true
+end
+
+
+function _M.upsert(red, doc_key, fields, ttl)
+ red:init_pipeline()
+ red[ "HSET" ](red, doc_key,
+ "partition", fields.partition,
+ "embedding", fields.embedding,
+ "response", fields.response,
+ "created_at", fields.created_at)
+ red:expire(doc_key, ttl)
+ local res, err = red:commit_pipeline()
+ if not res then
+ return nil, err
+ end
+ for _, reply in ipairs(res) do
+ if type(reply) == "table" and reply[1] == false then
+ return nil, reply[2]
+ end
+ end
+ return true
+end
+
+
+-- Returns the nearest hit { distance, response, created_at } or nil (no err)
on
+-- an empty result set.
+function _M.knn_search(red, target, index, partition, vec, top_k)
+ local query = "(@partition:{" .. partition .. "})=>[KNN " .. top_k ..
+ " @embedding $vec AS __score]"
+ local res, err = red[ "FT.SEARCH" ](red, index, query,
+ "PARAMS", 2, "vec", _M.pack_float32(vec),
+ "RETURN", 3, "__score", "response", "created_at",
+ "SORTBY", "__score",
+ "DIALECT", 2)
+ if not res then
+ -- self-heal: clear the memo on error so the next call re-runs
FT.CREATE.
+ ensured[memo_key(target, index)] = nil
+ return nil, err
+ end
+ -- RESP: { total, docKey1, {f, v, f, v, ...}, docKey2, {...}, ... }
+ if type(res) ~= "table" or (tonumber(res[1]) or 0) < 1 then
+ return nil
+ end
+ local fields = res[3]
+ if type(fields) ~= "table" then
+ return nil
+ end
+ local hit = {}
+ for i = 1, #fields, 2 do
+ local k, v = fields[i], fields[i + 1]
+ if k == "__score" then
+ hit.distance = tonumber(v)
+ elseif k == "response" then
+ hit.response = v
+ elseif k == "created_at" then
+ hit.created_at = tonumber(v)
+ end
+ end
+ if not hit.response or not hit.distance then
+ return nil
+ end
+ return hit
+end
+
+
+return _M
diff --git a/docs/en/latest/plugins/ai-cache.md
b/docs/en/latest/plugins/ai-cache.md
index bbeacdf73..d16a9a57f 100644
--- a/docs/en/latest/plugins/ai-cache.md
+++ b/docs/en/latest/plugins/ai-cache.md
@@ -40,7 +40,10 @@ import TabItem from '@theme/TabItem';
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.
+This Plugin supports two cache layers:
+
+- **Exact (L1):** A SHA-256 fingerprint of the effective prompt is used as the
Redis key. An identical prompt always hits the same entry.
+- **Semantic (L2):** When L1 misses, the prompt is embedded into a vector and
a nearest-neighbour search retrieves a past response whose embedding is within
the configured similarity threshold. L2 is disabled by default; enable it by
adding `"semantic"` to `layers`.
The `ai-cache` Plugin must be used with the [`ai-proxy`](./ai-proxy.md) or
[`ai-proxy-multi`](./ai-proxy-multi.md) Plugin.
@@ -61,12 +64,13 @@ Even with `cache_key.share_across_routes` enabled, the
cache key identifies the
| 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). |
+| cache_headers | boolean | False | true | | If true, emit the following
response headers: `X-AI-Cache-Status` (always), one of `MISS`, `HIT` (exact or
semantic cache hit), or `BYPASS`; `X-AI-Cache-Age`, the entry age in seconds,
on any cache hit; `X-AI-Cache-Similarity`, the cosine similarity score (0–1)
between the incoming prompt and the matched entry, on a semantic cache hit
only. |
| 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. |
+| layers | array[string] | False | ["exact"] | exact, semantic | Cache layers
to activate. `exact` performs an exact-match fingerprint lookup (L1) and is
always active; `"exact"` must always be present in this array. `semantic`
enables a vector-similarity lookup (L2) that is consulted only on an L1 miss.
At least one value is required; values must be unique. |
| 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`. |
@@ -78,6 +82,54 @@ Even with `cache_key.share_across_routes` enabled, the cache
key identifies the
| 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. |
+### Semantic (L2) Attributes
+
+:::caution Redis Stack required for semantic caching
+
+When `"semantic"` is included in `layers`, the configured Redis instance
**must** be [Redis Stack](https://redis.io/docs/stack/) (with the RediSearch
module). The L1 exact cache and the L2 semantic cache share the same Redis
connection configured by `redis_host` / `redis_port` etc.
+
+Vanilla Redis is sufficient when `layers` is omitted or contains only
`"exact"` (the default).
+
+:::
+
+The `semantic` object is required when `"semantic"` is present in `layers`. It
accepts the following attributes:
+
+| Name | Type | Required | Default | Valid values | Description |
+|------|------|----------|---------|--------------|-------------|
+| semantic.similarity_threshold | number | False | 0.95 | [0, 1] | Minimum
cosine similarity (equivalently, 1 − distance) required to consider a retrieved
vector a match. Requests below this threshold fall through to the upstream. |
+| semantic.top_k | integer | False | 1 | >= 1 | Number of nearest-neighbour
candidates to retrieve from the vector index. Only the highest-scoring result
is evaluated against `similarity_threshold`. |
+| semantic.distance_metric | string | False | `"cosine"` | `cosine` | Vector
distance metric. Only `cosine` is currently supported. |
+| semantic.ttl | integer | False | 86400 | >= 1 | Time-to-live, in seconds, of
a semantic (L2) cache entry. |
+| semantic.match.message_countback | integer | False | 1 | >= 1 | Number of
trailing `user`-role messages to include in the embedding input. |
+| semantic.match.ignore_system_prompts | boolean | False | true | | If true,
`system`-role messages are excluded from the embedding input. |
+| semantic.match.ignore_assistant_prompts | boolean | False | true | | If
true, `assistant`-role messages are excluded from the embedding input. |
+| semantic.match.ignore_tool_prompts | boolean | False | true | | If true,
`tool`-role messages are excluded from the embedding input. |
+| semantic.embedding | object | **True** | | | Embedding provider
configuration. Exactly one of `openai` or `azure_openai` must be specified. |
+| semantic.embedding.openai.endpoint | string | False | | | OpenAI-compatible
embedding API endpoint URL. Defaults to the public OpenAI API when omitted. |
+| semantic.embedding.openai.model | string | **True** | | | Embedding model
name (for example, `text-embedding-3-small`). |
+| semantic.embedding.openai.api_key | string | **True** | | | OpenAI API key.
Encrypted at rest in etcd. |
+| semantic.embedding.openai.dimensions | integer | False | | >= 1 | Override
the embedding output dimension for models that support it. |
+| semantic.embedding.openai.ssl_verify | boolean | False | true | | If true,
verifies the embedding service's certificate. |
+| semantic.embedding.openai.timeout | integer | False | 5000 | >= 1 | Request
timeout in milliseconds for the embedding service. |
+| semantic.embedding.azure_openai.endpoint | string | **True** | | | Azure
OpenAI deployment endpoint URL. |
+| semantic.embedding.azure_openai.api_key | string | **True** | | | Azure
OpenAI API key. Encrypted at rest in etcd. |
+| semantic.embedding.azure_openai.dimensions | integer | False | | >= 1 |
Override the embedding output dimension. |
+| semantic.embedding.azure_openai.ssl_verify | boolean | False | true | | If
true, verifies the embedding service's certificate. |
+| semantic.embedding.azure_openai.timeout | integer | False | 5000 | >= 1 |
Request timeout in milliseconds for the embedding service. |
+| semantic.vector_search | object | **True** | | | Vector index configuration.
|
+| semantic.vector_search.redis.index | string | False | `"ai-cache"` | |
RediSearch index name used as the vector store. |
+
+:::note Security: multi-tenant deployments
+
+Cache entries are scoped per route by default. In a multi-tenant deployment
where multiple consumers share a route, a cached response produced for one
consumer could be served to another. To prevent cross-tenant leakage:
+
+- Set `cache_key.include_consumer: true` to scope entries per consumer
identity.
+- Use `cache_key.include_vars` to add tenant-identifying NGINX variables (for
example `["http_x_tenant_id"]`) to the cache scope.
+
+Both L1 and L2 entries respect the same `cache_key` scoping rules.
+
+:::
+
## 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:
@@ -333,3 +385,132 @@ curl -i "http://127.0.0.1:9080/anything" -X POST \
```
The cache is skipped entirely (no lookup and no write-back), and the response
carries the `X-AI-Cache-Status: BYPASS` header.
+
+### Cache LLM Responses with Semantic Matching
+
+This example adds the semantic (L2) cache layer so that near-duplicate prompts
are served from cache even when the wording differs slightly. In addition to a
running Redis Stack instance, an OpenAI API key is needed for the embedding
service.
+
+:::caution
+
+The Redis instance must be [Redis Stack](https://redis.io/docs/stack/)
(RediSearch module). Vanilla Redis is not supported for semantic caching.
+
+:::
+
+<Tabs
+groupId="api"
+defaultValue="admin-api"
+values={[
+{label: 'Admin API', value: 'admin-api'},
+{label: 'ADC', value: 'adc'}
+]}>
+
+<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-semantic-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",
+ "layers": ["exact", "semantic"],
+ "semantic": {
+ "similarity_threshold": 0.92,
+ "embedding": {
+ "openai": {
+ "model": "text-embedding-3-small",
+ "api_key": "'"$OPENAI_API_KEY"'"
+ }
+ },
+ "vector_search": {
+ "redis": {
+ "index": "ai-cache"
+ }
+ }
+ }
+ }
+ }
+ }'
+```
+
+</TabItem>
+
+<TabItem value="adc">
+
+```yaml title="adc.yaml"
+services:
+ - name: ai-cache-semantic-service
+ routes:
+ - name: ai-cache-semantic-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
+ layers:
+ - exact
+ - semantic
+ semantic:
+ similarity_threshold: 0.92
+ embedding:
+ openai:
+ model: text-embedding-3-small
+ api_key: "${OPENAI_API_KEY}"
+ vector_search:
+ redis:
+ index: ai-cache
+```
+
+Synchronize the configuration to the gateway:
+
+```shell
+adc sync -f adc.yaml
+```
+
+</TabItem>
+
+</Tabs>
+
+Send an initial request:
+
+```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?" }]
}'
+```
+
+The first request is an L1 and L2 miss; the Plugin proxies it to the LLM,
embeds the prompt, and stores both the exact entry and the vector in Redis. The
response carries `X-AI-Cache-Status: MISS`.
+
+Send a semantically similar but differently worded request:
+
+```shell
+curl -i "http://127.0.0.1:9080/anything" -X POST \
+ -H "Content-Type: application/json" \
+ -d '{ "messages": [{ "role": "user", "content": "Can you explain what Apache
APISIX is?" }] }'
+```
+
+This request misses L1 (different fingerprint) but hits L2 (similar
embedding). The response is served from the semantic cache with:
+
+```text
+X-AI-Cache-Status: HIT
+X-AI-Cache-Age: 12
+X-AI-Cache-Similarity: 0.9487
+```
+
+The `X-AI-Cache-Similarity` header reports the cosine similarity (1 −
distance) between the incoming prompt and the matched cache entry.
diff --git a/docs/zh/latest/plugins/ai-cache.md
b/docs/zh/latest/plugins/ai-cache.md
index 3793317bd..16f3c2b7c 100644
--- a/docs/zh/latest/plugins/ai-cache.md
+++ b/docs/zh/latest/plugins/ai-cache.md
@@ -40,7 +40,10 @@ import TabItem from '@theme/TabItem';
`ai-cache` 插件缓存 LLM 响应,并在后续解析到相同提示词的请求中重放这些响应,从而为重复性工作负载(FAQ
机器人、文档问答、翻译等)降低上游的 Token 消耗与延迟。
-本次发布实现了**精确**缓存层(L1);语义缓存层(L2)计划在未来的版本中提供。
+该插件支持两个缓存层:
+
+- **精确缓存(L1):** 对有效提示词计算 SHA-256 指纹并用作 Redis 键。完全相同的提示词始终命中同一条缓存条目。
+- **语义缓存(L2):** 当 L1 未命中时,将提示词向量化,并通过最近邻搜索检索相似度在阈值以上的历史响应。L2 默认关闭;在 `layers`
中加入 `"semantic"` 即可启用。
`ai-cache` 插件必须与 [`ai-proxy`](./ai-proxy.md) 或
[`ai-proxy-multi`](./ai-proxy-multi.md) 插件一起使用。
@@ -61,12 +64,13 @@ import TabItem from '@theme/TabItem';
| 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`,表示缓存条目的存在时长,单位为秒)。 |
+| cache_headers | boolean | 否 | true | | 如果为
true,则输出以下响应头:`X-AI-Cache-Status`(始终输出),取值为 `MISS`、`HIT`(精确或语义缓存命中)或
`BYPASS`;`X-AI-Cache-Age`,表示缓存条目的存在时长(秒),在任意缓存命中时输出;`X-AI-Cache-Similarity`,表示请求提示词与命中条目之间的余弦相似度(0–1),仅在语义缓存命中时输出。
|
| 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`。 |
+| layers | array[string] | 否 | ["exact"] | exact, semantic | 要启用的缓存层。`exact`
执行精确指纹匹配(L1),始终处于激活状态,数组中必须包含 `"exact"`;`semantic` 启用向量相似度匹配(L2),仅在 L1
未命中时查询。至少需要一个值,且不可重复。 |
| redis_host | string | 是 | | | Redis 节点的地址。 |
| redis_port | integer | 否 | 6379 | >= 1 | Redis 节点的端口。 |
| redis_username | string | 否 | | | 使用 Redis ACL 时的用户名。如果使用传统的 `requirepass`
认证方式,则仅配置 `redis_password`。 |
@@ -78,6 +82,54 @@ import TabItem from '@theme/TabItem';
| redis_keepalive_timeout | integer | 否 | 10000 | >= 1000 | Redis
连接池的保活超时时间,单位为毫秒。 |
| redis_keepalive_pool | integer | 否 | 100 | >= 1 | Redis 保活连接池中的最大连接数。 |
+### 语义缓存(L2)属性
+
+:::caution 语义缓存需要 Redis Stack
+
+当 `layers` 中包含 `"semantic"` 时,所配置的 Redis 实例**必须**为 [Redis
Stack](https://redis.io/docs/stack/)(含 RediSearch 模块)。L1 精确缓存与 L2 语义缓存共用同一个由
`redis_host` / `redis_port` 等参数配置的 Redis 连接。
+
+若 `layers` 省略或仅包含 `"exact"`(默认值),则使用普通 Redis 即可。
+
+:::
+
+当 `layers` 中包含 `"semantic"` 时,`semantic` 对象为必填项,其属性如下:
+
+| 名称 | 类型 | 必选项 | 默认值 | 有效值 | 描述 |
+|------|------|--------|--------|--------|------|
+| semantic.similarity_threshold | number | 否 | 0.95 | [0, 1] |
将检索向量视为匹配所需的最小余弦相似度(即 1 − 距离)。低于该阈值的请求将透传至上游。 |
+| semantic.top_k | integer | 否 | 1 | >= 1 | 从向量索引中检索的最近邻候选数量。只有得分最高的结果会与
`similarity_threshold` 进行比较。 |
+| semantic.distance_metric | string | 否 | `"cosine"` | `cosine` |
向量距离度量方式。目前仅支持 `cosine`(余弦距离)。 |
+| semantic.ttl | integer | 否 | 86400 | >= 1 | 语义缓存(L2)条目的存活时间(TTL),单位为秒。 |
+| semantic.match.message_countback | integer | 否 | 1 | >= 1 | 纳入向量化输入的末尾
`user` 角色消息数量。 |
+| semantic.match.ignore_system_prompts | boolean | 否 | true | | 如果为 true,则
`system` 角色消息不纳入向量化输入。 |
+| semantic.match.ignore_assistant_prompts | boolean | 否 | true | | 如果为 true,则
`assistant` 角色消息不纳入向量化输入。 |
+| semantic.match.ignore_tool_prompts | boolean | 否 | true | | 如果为 true,则
`tool` 角色消息不纳入向量化输入。 |
+| semantic.embedding | object | **是** | | | 向量化服务配置。`openai` 与 `azure_openai`
二选一,必须且只能配置其中一个。 |
+| semantic.embedding.openai.endpoint | string | 否 | | | OpenAI 兼容的向量化 API 端点
URL。省略时默认使用 OpenAI 公共 API。 |
+| semantic.embedding.openai.model | string | **是** | | | 向量化模型名称(例如
`text-embedding-3-small`)。 |
+| semantic.embedding.openai.api_key | string | **是** | | | OpenAI API 密钥。存入
etcd 时使用 AES 加密。 |
+| semantic.embedding.openai.dimensions | integer | 否 | | >= 1 |
覆盖向量输出维度(仅对支持该参数的模型有效)。 |
+| semantic.embedding.openai.ssl_verify | boolean | 否 | true | | 如果为
true,验证向量化服务的证书。 |
+| semantic.embedding.openai.timeout | integer | 否 | 5000 | >= 1 |
向量化服务的请求超时时间(毫秒)。 |
+| semantic.embedding.azure_openai.endpoint | string | **是** | | | Azure OpenAI
部署端点 URL。 |
+| semantic.embedding.azure_openai.api_key | string | **是** | | | Azure OpenAI
API 密钥。存入 etcd 时使用 AES 加密。 |
+| semantic.embedding.azure_openai.dimensions | integer | 否 | | >= 1 |
覆盖向量输出维度。 |
+| semantic.embedding.azure_openai.ssl_verify | boolean | 否 | true | | 如果为
true,验证向量化服务的证书。 |
+| semantic.embedding.azure_openai.timeout | integer | 否 | 5000 | >= 1 |
向量化服务的请求超时时间(毫秒)。 |
+| semantic.vector_search | object | **是** | | | 向量索引配置。 |
+| semantic.vector_search.redis.index | string | 否 | `"ai-cache"` | | 作为向量存储使用的
RediSearch 索引名称。 |
+
+:::note 安全说明:多租户部署
+
+缓存条目默认按路由隔离。在多个消费者共用同一路由的多租户场景下,为某个消费者生成的缓存响应可能会被返回给其他消费者。为防止跨租户信息泄漏,请采取以下措施:
+
+- 将 `cache_key.include_consumer` 设为 `true`,按消费者身份隔离缓存条目。
+- 使用 `cache_key.include_vars` 添加标识租户的 NGINX 变量(例如
`["http_x_tenant_id"]`)到缓存作用域。
+
+L1 与 L2 缓存条目均遵循相同的 `cache_key` 作用域规则。
+
+:::
+
## 示例
以下示例使用 OpenAI 作为上游 LLM 服务提供商。请获取 [OpenAI API
key](https://openai.com/blog/openai-api),并将其与 Admin API key 一起保存到环境变量中:
@@ -333,3 +385,132 @@ curl -i "http://127.0.0.1:9080/anything" -X POST \
```
缓存被完全跳过(不查询、不回写),响应中携带 `X-AI-Cache-Status: BYPASS` 响应头。
+
+### 使用语义匹配缓存 LLM 响应
+
+以下示例启用语义缓存(L2)层,使措辞略有不同但语义相近的提示词也能命中缓存。除可用的 Redis Stack 实例外,还需要 OpenAI API
密钥用于向量化服务。
+
+:::caution
+
+Redis 实例必须为 [Redis Stack](https://redis.io/docs/stack/)(含 RediSearch
模块)。语义缓存不支持普通 Redis。
+
+:::
+
+<Tabs
+groupId="api"
+defaultValue="admin-api"
+values={[
+{label: 'Admin API', value: 'admin-api'},
+{label: 'ADC', value: 'adc'}
+]}>
+
+<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-semantic-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",
+ "layers": ["exact", "semantic"],
+ "semantic": {
+ "similarity_threshold": 0.92,
+ "embedding": {
+ "openai": {
+ "model": "text-embedding-3-small",
+ "api_key": "'"$OPENAI_API_KEY"'"
+ }
+ },
+ "vector_search": {
+ "redis": {
+ "index": "ai-cache"
+ }
+ }
+ }
+ }
+ }
+ }'
+```
+
+</TabItem>
+
+<TabItem value="adc">
+
+```yaml title="adc.yaml"
+services:
+ - name: ai-cache-semantic-service
+ routes:
+ - name: ai-cache-semantic-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
+ layers:
+ - exact
+ - semantic
+ semantic:
+ similarity_threshold: 0.92
+ embedding:
+ openai:
+ model: text-embedding-3-small
+ api_key: "${OPENAI_API_KEY}"
+ vector_search:
+ redis:
+ index: ai-cache
+```
+
+将配置同步到网关:
+
+```shell
+adc sync -f adc.yaml
+```
+
+</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?" }]
}'
+```
+
+首次请求同时未命中 L1 和 L2;插件将其代理到 LLM,对提示词进行向量化,并将精确缓存条目和向量分别存入 Redis。响应携带
`X-AI-Cache-Status: MISS`。
+
+发送语义相近但措辞不同的请求:
+
+```shell
+curl -i "http://127.0.0.1:9080/anything" -X POST \
+ -H "Content-Type: application/json" \
+ -d '{ "messages": [{ "role": "user", "content": "Can you explain what Apache
APISIX is?" }] }'
+```
+
+该请求未命中 L1(指纹不同),但命中了 L2(向量相似度超过阈值)。响应由语义缓存直接返回,并携带以下响应头:
+
+```text
+X-AI-Cache-Status: HIT
+X-AI-Cache-Age: 12
+X-AI-Cache-Similarity: 0.9487
+```
+
+`X-AI-Cache-Similarity` 响应头表示请求提示词与命中缓存条目之间的余弦相似度(1 − 距离)。
diff --git a/t/fixtures/openai/embeddings-capital-city.json
b/t/fixtures/openai/embeddings-capital-city.json
new file mode 100644
index 000000000..2095ad452
--- /dev/null
+++ b/t/fixtures/openai/embeddings-capital-city.json
@@ -0,0 +1,80 @@
+{
+ "object": "list",
+ "data": [
+ {
+ "object": "embedding",
+ "embedding": [
+ 0.125122,
+ 0.053131,
+ 0.143066,
+ 0.09198,
+ -0.188232,
+ 0.044159,
+ -0.028748,
+ -0.041748,
+ 0.072693,
+ 0.049744,
+ -0.080383,
+ -0.084106,
+ -0.29541,
+ -0.011398,
+ -0.174927,
+ 0.029709,
+ -0.146729,
+ 0.161987,
+ 0.328613,
+ -0.108459,
+ 0.089172,
+ -0.093079,
+ -0.162109,
+ 0.03894,
+ 0.291992,
+ 0.050995,
+ -0.160645,
+ 0.074646,
+ 0.029282,
+ 0.124512,
+ 0.195923,
+ -0.095764,
+ 0.014839,
+ 0.172974,
+ -0.12384,
+ -0.15686,
+ -0.129272,
+ -0.194946,
+ 0.105835,
+ 0.171021,
+ -0.03775,
+ -0.084412,
+ 0.047058,
+ 0.044617,
+ -0.116882,
+ -0.046753,
+ -0.076965,
+ -0.110168,
+ -0.113342,
+ 0.12323,
+ 0.137817,
+ 0.046082,
+ 0.135254,
+ 0.123291,
+ -0.072327,
+ 0.034637,
+ 0.066711,
+ 0.006908,
+ 0.206177,
+ 0.037903,
+ 0.093811,
+ 0.020889,
+ 0.115112,
+ 0.072815
+ ],
+ "index": 0
+ }
+ ],
+ "model": "text-embedding-3-small",
+ "usage": {
+ "prompt_tokens": 8,
+ "total_tokens": 8
+ }
+}
diff --git a/t/fixtures/openai/embeddings-capital.json
b/t/fixtures/openai/embeddings-capital.json
new file mode 100644
index 000000000..fd30fb5c2
--- /dev/null
+++ b/t/fixtures/openai/embeddings-capital.json
@@ -0,0 +1,80 @@
+{
+ "object": "list",
+ "data": [
+ {
+ "object": "embedding",
+ "embedding": [
+ 0.183716,
+ 0.069519,
+ 0.124023,
+ 0.107178,
+ -0.101868,
+ -0.012199,
+ -0.062805,
+ 0.063293,
+ 0.047943,
+ -0.045044,
+ 0.030533,
+ -0.106262,
+ -0.271729,
+ -0.066528,
+ -0.062561,
+ 0.102234,
+ -0.029175,
+ 0.085632,
+ 0.319336,
+ -0.107727,
+ 0.013527,
+ -0.044586,
+ -0.180786,
+ 0.052734,
+ 0.273926,
+ 0.031464,
+ -0.201172,
+ -0.032227,
+ 0.015778,
+ 0.17395,
+ 0.185791,
+ -0.111084,
+ -0.008842,
+ 0.190186,
+ -0.108459,
+ -0.17627,
+ -0.166016,
+ -0.173706,
+ 0.093994,
+ 0.131104,
+ -0.013832,
+ -0.057434,
+ 0.030212,
+ 0.05838,
+ -0.120728,
+ -0.13562,
+ -0.040771,
+ -0.176147,
+ -0.148438,
+ 0.121399,
+ 0.171265,
+ 0.017853,
+ 0.143311,
+ 0.143921,
+ -0.07428,
+ 0.018631,
+ 0.108704,
+ 0.042389,
+ 0.205933,
+ 0.068542,
+ 0.159424,
+ -0.045441,
+ 0.116211,
+ 0.019028
+ ],
+ "index": 0
+ }
+ ],
+ "model": "text-embedding-3-small",
+ "usage": {
+ "prompt_tokens": 7,
+ "total_tokens": 7
+ }
+}
diff --git a/t/fixtures/openai/embeddings-largest-city.json
b/t/fixtures/openai/embeddings-largest-city.json
new file mode 100644
index 000000000..a12eee4bc
--- /dev/null
+++ b/t/fixtures/openai/embeddings-largest-city.json
@@ -0,0 +1,80 @@
+{
+ "object": "list",
+ "data": [
+ {
+ "object": "embedding",
+ "embedding": [
+ 0.115112,
+ 0.029099,
+ 0.171021,
+ 0.131348,
+ -0.213135,
+ 0.01947,
+ -0.080261,
+ -0.019409,
+ -0.075928,
+ 0.186401,
+ -0.005394,
+ -0.125732,
+ -0.26416,
+ -0.128418,
+ -0.071533,
+ 0.053223,
+ -0.104248,
+ -0.003386,
+ 0.244629,
+ -0.121338,
+ 0.039551,
+ 0.001368,
+ -0.196899,
+ -0.075317,
+ 0.286377,
+ -0.040039,
+ -0.160889,
+ 0.210938,
+ 0.092285,
+ 0.185913,
+ 0.035889,
+ -0.078186,
+ 0.029709,
+ 0.090393,
+ -0.320068,
+ -0.235474,
+ -0.04541,
+ -0.006218,
+ 0.101807,
+ 0.282227,
+ 0.004814,
+ -0.023636,
+ 0.073242,
+ 0.009438,
+ 0.01104,
+ 0.08374,
+ -0.02684,
+ -0.235229,
+ -0.013588,
+ -0.0131,
+ 0.14856,
+ 0.007298,
+ -0.049011,
+ 0.01033,
+ 0.017471,
+ -0.06311,
+ 0.086182,
+ 0.091125,
+ 0.137817,
+ -0.002174,
+ 0.011612,
+ -0.019135,
+ 0.061584,
+ 0.062683
+ ],
+ "index": 0
+ }
+ ],
+ "model": "text-embedding-3-small",
+ "usage": {
+ "prompt_tokens": 8,
+ "total_tokens": 8
+ }
+}
diff --git a/t/fixtures/openai/embeddings-tire.json
b/t/fixtures/openai/embeddings-tire.json
new file mode 100644
index 000000000..adedaa79e
--- /dev/null
+++ b/t/fixtures/openai/embeddings-tire.json
@@ -0,0 +1,80 @@
+{
+ "object": "list",
+ "data": [
+ {
+ "object": "embedding",
+ "embedding": [
+ -0.03241,
+ -0.196411,
+ -0.089966,
+ -0.031738,
+ -0.146973,
+ -0.188354,
+ -0.072815,
+ -0.165894,
+ 0.094727,
+ 0.011772,
+ 0.13501,
+ 0.041718,
+ 0.044312,
+ 0.38208,
+ 0.186035,
+ -0.020386,
+ 0.19519,
+ 0.144165,
+ -0.170776,
+ -0.023315,
+ -0.055328,
+ -0.075562,
+ 0.078308,
+ -0.112854,
+ 0.082275,
+ -0.103516,
+ 0.25293,
+ -0.100952,
+ -0.160278,
+ 0.001236,
+ 0.010345,
+ -0.140747,
+ 0.024475,
+ -0.127319,
+ 0.074402,
+ -0.081299,
+ 0.033875,
+ 0.066711,
+ 0.186768,
+ -0.143066,
+ -0.139282,
+ 0.038696,
+ 0.072021,
+ 0.169067,
+ 0.012856,
+ 0.095337,
+ 0.107605,
+ 0.238037,
+ -0.070129,
+ -0.02948,
+ 0.114197,
+ -0.070679,
+ -0.001534,
+ 0.032501,
+ -0.118164,
+ 0.152954,
+ 0.058075,
+ -0.049316,
+ 0.222168,
+ 0.053436,
+ -0.173828,
+ -0.115051,
+ 0.098389,
+ -0.004353
+ ],
+ "index": 0
+ }
+ ],
+ "model": "text-embedding-3-small",
+ "usage": {
+ "prompt_tokens": 9,
+ "total_tokens": 9
+ }
+}
diff --git a/t/lib/ai_cache_mock.lua b/t/lib/ai_cache_mock.lua
new file mode 100644
index 000000000..733014bfc
--- /dev/null
+++ b/t/lib/ai_cache_mock.lua
@@ -0,0 +1,117 @@
+--
+-- 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.
+--
+
+--- Mock embeddings upstream for the ai-cache semantic (L2) tests.
+-- Replays the real text-embedding-3-small responses captured (at
dimensions=64)
+-- under t/fixtures/openai/embeddings-*.json, selecting one by the request
prompt
+-- so the suite exercises genuine embedding geometry without calling OpenAI:
+-- "...capital city..." -> capital_city "...capital..." -> capital
+-- "...largest..." -> largest_city "...tire..." -> tire
+
+local fixture_loader = require("lib.fixture_loader")
+local cjson = require("cjson.safe")
+
+local ngx = ngx
+local type = type
+
+local _M = {}
+
+local FIXTURE = {
+ capital = "openai/embeddings-capital.json",
+ capital_city = "openai/embeddings-capital-city.json",
+ largest_city = "openai/embeddings-largest-city.json",
+ tire = "openai/embeddings-tire.json",
+}
+
+
+local function serve(name)
+ local content, err = fixture_loader.load(name)
+ if not content then
+ ngx.status = 500
+ ngx.say(err or "fixture not found")
+ return
+ end
+ ngx.print(content)
+end
+
+
+-- Pick the embedding fixture the request is asking about. "capital city" is
+-- checked before "capital" so the paraphrase stays distinct from the anchor.
+local function pick(input)
+ if input:find("largest", 1, true) then
+ return FIXTURE.largest_city
+ elseif input:find("tire", 1, true) then
+ return FIXTURE.tire
+ elseif input:find("capital city", 1, true) then
+ return FIXTURE.capital_city
+ elseif input:find("capital", 1, true) then
+ return FIXTURE.capital
+ end
+ return FIXTURE.tire -- default: an unrelated/orthogonal vector
+end
+
+
+-- Prompt-keyed embeddings endpoint used by the end-to-end semantic tests.
+function _M.embeddings()
+ ngx.req.read_body()
+ local body = cjson.decode(ngx.req.get_body_data() or "{}") or {}
+ local input = type(body.input) == "string" and body.input or ""
+ serve(pick(input))
+end
+
+
+-- openai driver unit mock: must carry Authorization: Bearer.
+function _M.embeddings_openai()
+ if ngx.req.get_headers()["authorization"] ~= "Bearer test-key" then
+ ngx.status = 401
+ ngx.say("bad authorization")
+ return
+ end
+ serve(FIXTURE.capital)
+end
+
+
+-- azure_openai driver unit mock: must carry the api-key header, never
Authorization.
+function _M.embeddings_azure()
+ if ngx.req.get_headers()["api-key"] ~= "test-key" then
+ ngx.status = 401
+ ngx.say("missing api-key header")
+ return
+ end
+ if ngx.req.get_headers()["authorization"] then
+ ngx.status = 400
+ ngx.say("azure driver must not send Authorization")
+ return
+ end
+ serve(FIXTURE.capital)
+end
+
+
+-- always 5xx: drives the embedding-provider fail-open path.
+function _M.broken()
+ ngx.status = 500
+ ngx.say("embedding upstream error")
+end
+
+
+-- well-formed HTTP 200 but a body the driver must reject.
+function _M.malformed()
+ ngx.say('{"data":[]}')
+end
+
+
+return _M
diff --git a/t/plugin/ai-cache-semantic.t b/t/plugin/ai-cache-semantic.t
new file mode 100644
index 000000000..e7b22a7c6
--- /dev/null
+++ b/t/plugin/ai-cache-semantic.t
@@ -0,0 +1,1527 @@
+#
+# 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();
+
+# Real text-embedding-3-small responses (captured at dimensions=64) live as
+# fixtures under t/fixtures/openai/embeddings-*.json, and the mock that replays
+# them by prompt lives in t/lib/ai_cache_mock.lua -- so every
HIT/MISS/threshold
+# decision is driven by genuine embedding geometry, hermetically:
+# cos(capital, capital_city) = 0.922 (paraphrase -> HIT at threshold 0.9)
+# cos(capital, largest_city) = 0.706 (related -> the real threshold
knee)
+# cos(capital, tire) = -0.148 (unrelated -> always a MISS)
+
+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);
+ }
+
+ # Only the embedding call is mocked (the chat completion uses the shared
+ # :1980 X-AI-Fixture upstream); the mock logic lives in
lib/ai_cache_mock.lua.
+ my $http_config = $block->http_config // <<_EOC_;
+ server {
+ listen 6724;
+ default_type 'application/json';
+
+ location /v1/embeddings {
+ content_by_lua_block { require("lib.ai_cache_mock").embeddings() }
+ }
+ location /v1/embeddings-broken {
+ content_by_lua_block { require("lib.ai_cache_mock").broken() }
+ }
+ location /v1/embeddings-malformed {
+ content_by_lua_block { require("lib.ai_cache_mock").malformed() }
+ }
+ location /v1/embeddings-openai {
+ content_by_lua_block {
require("lib.ai_cache_mock").embeddings_openai() }
+ }
+ location /v1/embeddings-azure {
+ content_by_lua_block {
require("lib.ai_cache_mock").embeddings_azure() }
+ }
+ }
+_EOC_
+ $block->set_value("http_config", $http_config);
+});
+
+run_tests();
+
+__DATA__
+
+=== TEST 1: layers defaults to ["exact"]; a minimal exact-only config is valid
+--- 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" })
+ ngx.say(ok and "passed" or err)
+ }
+ }
+--- response_body
+passed
+
+
+
+=== TEST 2: an explicit layers=["exact"] is valid (no semantic block required)
+--- 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",
+ layers = { "exact" },
+ })
+ ngx.say(ok and "passed" or err)
+ }
+ }
+--- response_body
+passed
+
+
+
+=== TEST 3: layers=["exact","semantic"] without a semantic block is rejected
+--- 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",
+ layers = { "exact", "semantic" },
+ })
+ ngx.say(ok and "passed" or err)
+ }
+ }
+--- response_body eval
+qr/allOf 1 failed: then clause did not match/
+
+
+
+=== TEST 4: a full openai semantic config is valid
+--- 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",
+ layers = { "exact", "semantic" },
+ semantic = {
+ similarity_threshold = 0.9,
+ embedding = { openai = {
+ endpoint = "https://api.openai.com/v1/embeddings",
+ model = "text-embedding-3-small",
+ api_key = "test-key",
+ dimensions = 1536,
+ } },
+ vector_search = { redis = { index = "ai-cache" } },
+ },
+ })
+ ngx.say(ok and "passed" or err)
+ }
+ }
+--- response_body
+passed
+
+
+
+=== TEST 5: a full azure_openai semantic config is valid (endpoint+api_key
required)
+--- 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",
+ layers = { "exact", "semantic" },
+ semantic = {
+ embedding = { azure_openai = {
+ endpoint =
"https://my.openai.azure.com/.../embeddings?api-version=2024-02-01",
+ api_key = "test-key",
+ } },
+ vector_search = { redis = {} },
+ },
+ })
+ ngx.say(ok and "passed" or err)
+ }
+ }
+--- response_body
+passed
+
+
+
+=== TEST 6: layers=["semantic"] without "exact" is rejected (exact is always
required)
+--- 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",
+ layers = { "semantic" },
+ semantic = {
+ embedding = { openai = { model = "text-embedding-3-small",
api_key = "test-key" } },
+ vector_search = { redis = {} },
+ },
+ })
+ ngx.say(ok and "passed" or err)
+ }
+ }
+--- response_body eval
+qr/property "layers" validation failed: failed to check contains/
+
+
+
+=== TEST 7: distance_metric "euclidean" is rejected (cosine-only in this layer)
+--- 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",
+ layers = { "exact", "semantic" },
+ semantic = {
+ distance_metric = "euclidean",
+ embedding = { openai = { model = "text-embedding-3-small",
api_key = "test-key" } },
+ vector_search = { redis = {} },
+ },
+ })
+ ngx.say(ok and "passed" or err)
+ }
+ }
+--- response_body eval
+qr/property "distance_metric" validation failed: matches none of the enum
values/
+
+
+
+=== TEST 8: similarity_threshold above 1 is rejected (must be within [0, 1])
+--- 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",
+ layers = { "exact", "semantic" },
+ semantic = {
+ similarity_threshold = 1.5,
+ embedding = { openai = { model = "text-embedding-3-small",
api_key = "test-key" } },
+ vector_search = { redis = {} },
+ },
+ })
+ ngx.say(ok and "passed" or err)
+ }
+ }
+--- response_body eval
+qr/property "similarity_threshold" validation failed: expected 1\.5 to be at
most 1/
+
+
+
+=== TEST 9: semantic.ttl below 1 is rejected
+--- 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",
+ layers = { "exact", "semantic" },
+ semantic = {
+ ttl = 0,
+ embedding = { openai = { model = "text-embedding-3-small",
api_key = "test-key" } },
+ vector_search = { redis = {} },
+ },
+ })
+ ngx.say(ok and "passed" or err)
+ }
+ }
+--- response_body eval
+qr/property "ttl" validation failed: expected 0 to be at least 1/
+
+
+
+=== TEST 10: embedding must name exactly one provider -- neither is rejected
+--- 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",
+ layers = { "exact", "semantic" },
+ semantic = {
+ embedding = {},
+ vector_search = { redis = {} },
+ },
+ })
+ ngx.say(ok and "passed" or err)
+ }
+ }
+--- response_body eval
+qr/property "embedding" validation failed: value should match only one schema,
but matches none/
+
+
+
+=== TEST 11: embedding must name exactly one provider -- both is rejected
(oneOf)
+--- 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",
+ layers = { "exact", "semantic" },
+ semantic = {
+ embedding = {
+ openai = { model = "text-embedding-3-small",
api_key = "test-key" },
+ azure_openai = { endpoint = "https://a.b/e", api_key =
"test-key" },
+ },
+ vector_search = { redis = {} },
+ },
+ })
+ ngx.say(ok and "passed" or err)
+ }
+ }
+--- response_body eval
+qr/property "embedding" validation failed: value should match only one schema,
but matches both schemas 1 and 2/
+
+
+
+=== TEST 12: match.message_countback below 1 is rejected
+--- 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",
+ layers = { "exact", "semantic" },
+ semantic = {
+ match = { message_countback = 0 },
+ embedding = { openai = { model = "text-embedding-3-small",
api_key = "test-key" } },
+ vector_search = { redis = {} },
+ },
+ })
+ ngx.say(ok and "passed" or err)
+ }
+ }
+--- response_body eval
+qr/property "message_countback" validation failed: expected 0 to be at least 1/
+
+
+
+=== TEST 13: context_fingerprint ignores message TEXT but reacts to
model/params (key.lua unit)
+--- config
+ location /t {
+ content_by_lua_block {
+ local key = require("apisix.plugins.ai-cache.key")
+
+ -- the context fingerprint deduplicates by the EFFECTIVE request
context
+ -- (provider/model/params/instance) with the message wording
removed, so a
+ -- paraphrase collapses to one fingerprint while a parameter
change does not.
+ local function ctx()
+ return { ai_client_protocol = "openai-chat",
+ picked_ai_instance = { provider = "openai",
+ options = { model = "gpt-4o"
}, override = {} },
+ var = {} }
+ end
+ local a = key.context_fingerprint(ctx(), { model = "gpt-4o",
temperature = 0.2,
+ messages = {{ role = "user", content = "how do I return an
item?" }} })
+ local b = key.context_fingerprint(ctx(), { model = "gpt-4o",
temperature = 0.2,
+ messages = {{ role = "user", content = "what is the return
policy?" }} })
+ local c = key.context_fingerprint(ctx(), { model = "gpt-4o",
temperature = 0.9,
+ messages = {{ role = "user", content = "how do I return an
item?" }} })
+
+ assert(a == b, "differently-worded prompts must share one context
fingerprint")
+ assert(a ~= c, "a parameter change (temperature) must flip the
context fingerprint")
+ ngx.say("passed")
+ }
+ }
+--- response_body
+passed
+
+
+
+=== TEST 14: partition is stable and isolates by scope and effective model
(key.lua unit)
+--- config
+ location /t {
+ content_by_lua_block {
+ local key = require("apisix.plugins.ai-cache.key")
+
+ -- partition = sha256(scope | context_repr); it is what segregates
the L2
+ -- vector index so a paraphrase can only ever hit within its own
+ -- tenant + route + effective-model cell.
+ local function ctx(tenant, model)
+ return { ai_client_protocol = "openai-chat",
+ picked_ai_instance = { provider = "openai",
+ options = { model = model or
"gpt-4o" }, override = {} },
+ var = { route_id = "1", http_x_tenant = tenant } }
+ end
+ local body = { model = "gpt-4o", messages = {{ role = "user",
content = "hi" }} }
+ local conf = { cache_key = { include_vars = { "http_x_tenant" } } }
+
+ assert(key.partition(conf, ctx("acme"), body) ==
key.partition(conf, ctx("acme"), body),
+ "the same inputs must produce the same partition")
+ assert(key.partition(conf, ctx("acme"), body) ~=
key.partition(conf, ctx("globex"), body),
+ "an include_vars (tenant) change must change the partition")
+ assert(key.partition(conf, ctx("acme", "gpt-4o"), body)
+ ~= key.partition(conf, ctx("acme", "gpt-4o-mini"),
body),
+ "a different effective model must change the partition")
+ ngx.say("passed")
+ }
+ }
+--- response_body
+passed
+
+
+
+=== TEST 15: extract_embed_text keeps the last user message, skipping
system/assistant (semantic.lua unit)
+--- config
+ location /t {
+ content_by_lua_block {
+ local semantic = require("apisix.plugins.ai-cache.semantic")
+ local msgs = {
+ { role = "system", content = "you are helpful" },
+ { role = "user", content = "first question" },
+ { role = "assistant", content = "an answer" },
+ { role = "user", content = "the real question" },
+ }
+ -- countback 1 -> only the most recent user message
+ ngx.say(semantic.extract_embed_text(msgs, { message_countback = 1
}))
+ -- countback 2 -> the two most recent kept (user) messages,
newline-joined
+ ngx.say(semantic.extract_embed_text(msgs, { message_countback = 2
}))
+ }
+ }
+--- response_body
+the real question
+first question
+the real question
+
+
+
+=== TEST 16: extract_embed_text flattens multimodal text blocks, dropping
non-text (semantic.lua unit)
+--- config
+ location /t {
+ content_by_lua_block {
+ local semantic = require("apisix.plugins.ai-cache.semantic")
+ local msgs = {{ role = "user", content = {
+ { type = "text", text = "describe" },
+ { type = "image_url", image_url = { url = "http://x/y.png" } },
+ { type = "text", text = "this image" },
+ }}}
+ ngx.say(semantic.extract_embed_text(msgs, {}))
+ }
+ }
+--- response_body
+describe
+this image
+
+
+
+=== TEST 17: extract_embed_text honours ignore flags (system kept when not
ignored) (semantic.lua unit)
+--- config
+ location /t {
+ content_by_lua_block {
+ local semantic = require("apisix.plugins.ai-cache.semantic")
+ local msgs = {
+ { role = "system", content = "system preamble" },
+ { role = "user", content = "the question" },
+ }
+ -- default: system prompts ignored -> only the user message
+ ngx.say(semantic.extract_embed_text(msgs, { message_countback = 2
}))
+ ngx.say("---")
+ -- opt-in: keep system prompts -> both, newline-joined
+ ngx.say(semantic.extract_embed_text(msgs,
+ { message_countback = 2, ignore_system_prompts = false }))
+ }
+ }
+--- response_body
+the question
+---
+system preamble
+the question
+
+
+
+=== TEST 18: openai embeddings driver extracts data[1].embedding (real
1536-dim vector) + sends Bearer
+--- config
+ location /t {
+ content_by_lua_block {
+ local http = require("resty.http")
+ local drv = require("apisix.plugins.ai-cache.embeddings.openai")
+ -- the mock returns 401 unless Authorization: Bearer test-key is
present,
+ -- so a successful vector also proves the driver sent the bearer
token.
+ local vec, err = drv.get_embeddings(
+ { endpoint = "http://127.0.0.1:6724/v1/embeddings-openai",
+ model = "text-embedding-3-small", api_key = "test-key" },
+ "What is the capital of France?", http.new(), false)
+ if not vec then ngx.say("err:", err); return end
+ -- the real captured "capital" embedding (text-embedding-3-small)
+ ngx.say("dim=", #vec)
+ ngx.say(string.format("v1=%.6f v2=%.6f v3=%.6f", vec[1], vec[2],
vec[3]))
+ }
+ }
+--- response_body
+dim=64
+v1=0.183716 v2=0.069519 v3=0.124023
+
+
+
+=== TEST 19: openai embeddings driver fails closed on a non-2xx response
+--- config
+ location /t {
+ content_by_lua_block {
+ local http = require("resty.http")
+ local drv = require("apisix.plugins.ai-cache.embeddings.openai")
+ local vec = drv.get_embeddings(
+ { endpoint = "http://127.0.0.1:6724/v1/embeddings-broken",
+ model = "text-embedding-3-small", api_key = "test-key" },
+ "hi", http.new(), false)
+ ngx.say(vec and "got-vec" or "nil-on-error")
+ }
+ }
+--- response_body
+nil-on-error
+
+
+
+=== TEST 20: openai embeddings driver rejects a malformed (well-formed-HTTP)
body
+--- config
+ location /t {
+ content_by_lua_block {
+ local http = require("resty.http")
+ local drv = require("apisix.plugins.ai-cache.embeddings.openai")
+ local vec, err = drv.get_embeddings(
+ { endpoint = "http://127.0.0.1:6724/v1/embeddings-malformed",
+ model = "text-embedding-3-small", api_key = "test-key" },
+ "hi", http.new(), false)
+ ngx.say(vec and "got-vec" or "nil-on-error")
+ ngx.say(err)
+ }
+ }
+--- response_body
+nil-on-error
+malformed embeddings response
+
+
+
+=== TEST 21: azure_openai embeddings driver sends api-key (not Authorization),
returns the real vector
+--- config
+ location /t {
+ content_by_lua_block {
+ local http = require("resty.http")
+ local drv =
require("apisix.plugins.ai-cache.embeddings.azure_openai")
+ -- the mock 401s without api-key and 400s if Authorization is
present,
+ -- so a vector here proves the azure auth scheme is used
exclusively.
+ local vec, err = drv.get_embeddings(
+ { endpoint = "http://127.0.0.1:6724/v1/embeddings-azure",
api_key = "test-key" },
+ "What is the capital of France?", http.new(), false)
+ if not vec then ngx.say("err:", err); return end
+ ngx.say("dim=", #vec)
+ ngx.say(string.format("v1=%.6f v2=%.6f v3=%.6f", vec[1], vec[2],
vec[3]))
+ }
+ }
+--- response_body
+dim=64
+v1=0.183716 v2=0.069519 v3=0.124023
+
+
+
+=== TEST 22: azure_openai embeddings driver fails closed on a non-2xx response
+--- config
+ location /t {
+ content_by_lua_block {
+ local http = require("resty.http")
+ local drv =
require("apisix.plugins.ai-cache.embeddings.azure_openai")
+ local vec = drv.get_embeddings(
+ { endpoint = "http://127.0.0.1:6724/v1/embeddings-broken",
api_key = "test-key" },
+ "hi", http.new(), false)
+ ngx.say(vec and "got-vec" or "nil-on-error")
+ }
+ }
+--- response_body
+nil-on-error
+
+
+
+=== TEST 23: pack_float32 produces a 4-bytes-per-element little-endian blob
(vector-search unit)
+--- config
+ location /t {
+ content_by_lua_block {
+ local vs = require("apisix.plugins.ai-cache.vector-search.redis")
+ local blob = vs.pack_float32({ 1.0, 0.0, 0.0 })
+ ngx.say(type(blob), ":", #blob)
+ -- 1.0f little-endian = 00 00 80 3f
+ ngx.say(string.byte(blob, 3), ",", string.byte(blob, 4))
+ }
+ }
+--- response_body
+string:12
+128,63
+
+
+
+=== TEST 24: ensure_index creates the index and is idempotent on a second call
(vector-search unit)
+--- config
+ location /t {
+ content_by_lua_block {
+ local redis_util = require("apisix.utils.redis")
+ local vs = require("apisix.plugins.ai-cache.vector-search.redis")
+ local red = assert(redis_util.new({
+ redis_host = "127.0.0.1", redis_port = 6379, redis_database =
0 }))
+ red:flushdb()
+ local tgt = "127.0.0.1#6379#0"
+ assert(vs.ensure_index(red, tgt, "ut-create:idx:3",
"ut-create:l2:", 3))
+ assert(vs.ensure_index(red, tgt, "ut-create:idx:3",
"ut-create:l2:", 3)) -- idempotent
+ ngx.say("passed")
+ }
+ }
+--- response_body
+passed
+
+
+
+=== TEST 25: upsert + knn_search round-trip returns the nearest doc by cosine
distance (vector-search unit)
+--- config
+ location /t {
+ content_by_lua_block {
+ local redis_util = require("apisix.utils.redis")
+ local vs = require("apisix.plugins.ai-cache.vector-search.redis")
+ local red = assert(redis_util.new({
+ redis_host = "127.0.0.1", redis_port = 6379, redis_database =
0 }))
+ red:flushdb()
+ local tgt = "127.0.0.1#6379#0"
+ assert(vs.ensure_index(red, tgt, "ut-knn:idx:3", "ut-knn:l2:", 3))
+
+ assert(vs.upsert(red, "ut-knn:l2:p1:near",
+ { partition = "p1", embedding = vs.pack_float32({ 1.0, 0.0,
0.0 }),
+ response = [[{"answer":"NEAR"}]], created_at = 100 }, 600))
+ assert(vs.upsert(red, "ut-knn:l2:p1:far",
+ { partition = "p1", embedding = vs.pack_float32({ 0.0, 1.0,
0.0 }),
+ response = [[{"answer":"FAR"}]], created_at = 100 }, 600))
+
+ local hit, err = vs.knn_search(red, tgt, "ut-knn:idx:3", "p1", {
0.99, 0.01, 0.0 }, 1)
+ if not hit then ngx.say("no-hit:", err or ""); return end
+ ngx.say(hit.response)
+ ngx.say(hit.distance < 0.01 and "near-distance" or ("dist=" ..
hit.distance))
+ }
+ }
+--- response_body
+{"answer":"NEAR"}
+near-distance
+
+
+
+=== TEST 26: knn_search returns nil and no error when the partition holds no
docs (vector-search unit)
+--- config
+ location /t {
+ content_by_lua_block {
+ local redis_util = require("apisix.utils.redis")
+ local vs = require("apisix.plugins.ai-cache.vector-search.redis")
+ local red = assert(redis_util.new({
+ redis_host = "127.0.0.1", redis_port = 6379, redis_database =
0 }))
+ red:flushdb()
+ local tgt = "127.0.0.1#6379#0"
+ assert(vs.ensure_index(red, tgt, "ut-empty:idx:3", "ut-empty:l2:",
3))
+ -- partitions are sha256 hex in production; use a hex-shaped value
here
+ local hit, err = vs.knn_search(red, tgt, "ut-empty:idx:3",
"deadbeef", { 1, 0, 0 }, 1)
+ ngx.say(hit == nil and not err and "clean-miss" or "unexpected")
+ }
+ }
+--- response_body
+clean-miss
+
+
+
+=== TEST 27: knn_search is partition-scoped -- an identical vector in another
partition is invisible (vector-search unit)
+--- config
+ location /t {
+ content_by_lua_block {
+ local redis_util = require("apisix.utils.redis")
+ local vs = require("apisix.plugins.ai-cache.vector-search.redis")
+ local red = assert(redis_util.new({
+ redis_host = "127.0.0.1", redis_port = 6379, redis_database =
0 }))
+ red:flushdb()
+ local tgt = "127.0.0.1#6379#0"
+ assert(vs.ensure_index(red, tgt, "ut-part:idx:3", "ut-part:l2:",
3))
+
+ -- two docs with the SAME vector but different partition tags
+ assert(vs.upsert(red, "ut-part:l2:p1:d1",
+ { partition = "p1", embedding = vs.pack_float32({ 1.0, 0.0,
0.0 }),
+ response = [[{"answer":"P1"}]], created_at = 100 }, 600))
+ assert(vs.upsert(red, "ut-part:l2:p2:d2",
+ { partition = "p2", embedding = vs.pack_float32({ 1.0, 0.0,
0.0 }),
+ response = [[{"answer":"P2"}]], created_at = 100 }, 600))
+
+ local hit = vs.knn_search(red, tgt, "ut-part:idx:3", "p1", { 1, 0,
0 }, 1)
+ ngx.say(hit and hit.response or "no-hit")
+ -- querying a partition with no docs must not leak p1/p2's
identical vector
+ local other = vs.knn_search(red, tgt, "ut-part:idx:3", "p3", { 1,
0, 0 }, 1)
+ ngx.say(other == nil and "isolated" or "leaked")
+ }
+ }
+--- response_body
+{"answer":"P1"}
+isolated
+
+
+
+=== TEST 28: set a semantic route (exact+semantic, default "ai-cache" index,
threshold 0.9)
+--- 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": "/semantic",
+ "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,
+ "layers": ["exact", "semantic"],
+ "semantic": {
+ "similarity_threshold": 0.9,
+ "embedding": {
+ "openai": {
+ "endpoint":
"http://127.0.0.1:6724/v1/embeddings",
+ "model": "text-embedding-3-small",
+ "api_key": "test-key"
+ }
+ },
+ "vector_search": { "redis": {} }
+ }
+ }
+ }
+ }]]
+ )
+ if code >= 300 then ngx.status = code end
+ ngx.say(body)
+ }
+ }
+--- response_body
+passed
+
+
+
+=== TEST 29: a cold prompt ("capital of France") is a semantic MISS and is
proxied upstream
+--- request
+POST /semantic
+{"model":"gpt-4o","messages":[{"role":"user","content":"What is the capital of
France?"}]}
+--- 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.5
+
+
+
+=== TEST 30: a real paraphrase ("capital city of France", cos 0.922) is a
semantic L2 HIT
+--- request
+POST /semantic
+{"model":"gpt-4o","messages":[{"role":"user","content":"What's the capital
city of France?"}]}
+--- response_headers_like
+X-AI-Cache-Status: HIT
+X-AI-Cache-Similarity: 0\.92\d\d
+X-AI-Cache-Age: \d+
+--- response_body_like eval
+qr/1 \+ 1 = 2/
+--- wait: 0.3
+
+
+
+=== TEST 31: repeating the paraphrase is now an exact L1 HIT (backfill -- no
similarity header)
+--- request
+POST /semantic
+{"model":"gpt-4o","messages":[{"role":"user","content":"What's the capital
city of France?"}]}
+--- response_headers
+X-AI-Cache-Status: HIT
+! X-AI-Cache-Similarity
+--- response_body_like eval
+qr/1 \+ 1 = 2/
+
+
+
+=== TEST 32: an unrelated prompt ("flat car tire", cos -0.148) is a MISS (far
below threshold)
+--- request
+POST /semantic
+{"model":"gpt-4o","messages":[{"role":"user","content":"How do I change a flat
car tire?"}]}
+--- 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 33: the default "ai-cache:l2:" namespace was populated by the
semantic writes
+--- config
+ location /t {
+ content_by_lua_block {
+ local redis_util = require("apisix.utils.redis")
+ local red = assert(redis_util.new({
+ redis_host = "127.0.0.1", redis_port = 6379, redis_database =
0 }))
+ local keys = red:keys("ai-cache:l2:*")
+ red:close()
+ ngx.say("default-l2=", (keys and #keys > 0) and "present" or
"absent")
+ }
+ }
+--- response_body
+default-l2=present
+
+
+
+=== TEST 34: set a single semantic route with NO options.model (effective
model = body model)
+--- 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": "/semantic",
+ "plugins": {
+ "ai-proxy": {
+ "provider": "openai",
+ "auth": { "header": { "Authorization": "Bearer
test-key" } },
+ "override": { "endpoint": "http://127.0.0.1:1980" }
+ },
+ "ai-cache": {
+ "redis_host": "127.0.0.1",
+ "redis_port": 6379,
+ "layers": ["exact", "semantic"],
+ "semantic": {
+ "similarity_threshold": 0.9,
+ "embedding": {
+ "openai": {
+ "endpoint":
"http://127.0.0.1:6724/v1/embeddings",
+ "model": "text-embedding-3-small",
+ "api_key": "test-key"
+ }
+ },
+ "vector_search": { "redis": { "index":
"idx-model" } }
+ }
+ }
+ }
+ }]]
+ )
+ if code >= 300 then ngx.status = code end
+ ngx.say(body)
+ }
+ }
+--- response_body
+passed
+
+
+
+=== TEST 35: gpt-4o cold request is a MISS (warms the gpt-4o partition)
+--- request
+POST /semantic
+{"model":"gpt-4o","messages":[{"role":"user","content":"What is the capital of
France?"}]}
+--- more_headers
+X-AI-Fixture: openai/chat-basic.json
+--- response_headers
+X-AI-Cache-Status: MISS
+--- wait: 0.5
+
+
+
+=== TEST 36: gpt-4o-mini with the same prompt and same vector is still a MISS
(partition is model-scoped)
+--- request
+POST /semantic
+{"model":"gpt-4o-mini","messages":[{"role":"user","content":"What is the
capital of France?"}]}
+--- more_headers
+X-AI-Fixture: openai/chat-basic.json
+--- response_headers
+X-AI-Cache-Status: MISS
+--- wait: 0.5
+
+
+
+=== TEST 37: a gpt-4o paraphrase is a semantic HIT (gpt-4o's L2 partition is
intact and isolated)
+--- request
+POST /semantic
+{"model":"gpt-4o","messages":[{"role":"user","content":"What's the capital
city of France?"}]}
+--- response_headers_like
+X-AI-Cache-Status: HIT
+X-AI-Cache-Similarity: 0\.92\d\d
+
+
+
+=== TEST 38: set a semantic route isolated per-tenant via include_vars
+--- 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": "/semantic",
+ "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,
+ "layers": ["exact", "semantic"],
+ "cache_key": { "include_vars": ["http_x_tenant"] },
+ "semantic": {
+ "similarity_threshold": 0.9,
+ "embedding": {
+ "openai": {
+ "endpoint":
"http://127.0.0.1:6724/v1/embeddings",
+ "model": "text-embedding-3-small",
+ "api_key": "test-key"
+ }
+ },
+ "vector_search": { "redis": { "index":
"idx-tenant" } }
+ }
+ }
+ }
+ }]]
+ )
+ if code >= 300 then ngx.status = code end
+ ngx.say(body)
+ }
+ }
+--- response_body
+passed
+
+
+
+=== TEST 39: tenant acme cold request is a MISS (warms the acme scope)
+--- request
+POST /semantic
+{"model":"gpt-4o","messages":[{"role":"user","content":"What is the capital of
France?"}]}
+--- more_headers
+X-AI-Fixture: openai/chat-basic.json
+X-Tenant: acme
+--- response_headers
+X-AI-Cache-Status: MISS
+--- wait: 0.5
+
+
+
+=== TEST 40: tenant globex with the same prompt and vector is a MISS (no
cross-tenant semantic leak)
+--- request
+POST /semantic
+{"model":"gpt-4o","messages":[{"role":"user","content":"What is the capital of
France?"}]}
+--- more_headers
+X-AI-Fixture: openai/chat-basic.json
+X-Tenant: globex
+--- response_headers
+X-AI-Cache-Status: MISS
+--- wait: 0.5
+
+
+
+=== TEST 41: a tenant acme paraphrase is a semantic HIT (acme's own scope
persisted)
+--- request
+POST /semantic
+{"model":"gpt-4o","messages":[{"role":"user","content":"What's the capital
city of France?"}]}
+--- more_headers
+X-Tenant: acme
+--- response_headers_like
+X-AI-Cache-Status: HIT
+X-AI-Cache-Similarity: 0\.92\d\d
+
+
+
+=== TEST 42: set a semantic route whose embedding endpoint always 5xxs
(fail-open)
+--- 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": "/semantic",
+ "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,
+ "layers": ["exact", "semantic"],
+ "semantic": {
+ "embedding": {
+ "openai": {
+ "endpoint":
"http://127.0.0.1:6724/v1/embeddings-broken",
+ "model": "text-embedding-3-small",
+ "api_key": "test-key"
+ }
+ },
+ "vector_search": { "redis": { "index":
"idx-failopen" } }
+ }
+ }
+ }
+ }]]
+ )
+ if code >= 300 then ngx.status = code end
+ ngx.say(body)
+ }
+ }
+--- response_body
+passed
+
+
+
+=== TEST 43: a broken embedding provider fails open to a MISS; exact (L1)
still warms
+--- request
+POST /semantic
+{"model":"gpt-4o","messages":[{"role":"user","content":"What is the capital of
France?"}]}
+--- more_headers
+X-AI-Fixture: openai/chat-basic.json
+--- response_headers
+X-AI-Cache-Status: MISS
+--- response_body_like eval
+qr/1 \+ 1 = 2/
+--- error_log
+ai-cache: embedding failed, fail-open as MISS
+--- wait: 0.5
+
+
+
+=== TEST 44: the identical request is an exact L1 HIT (the broken embedder
never corrupted L1)
+--- request
+POST /semantic
+{"model":"gpt-4o","messages":[{"role":"user","content":"What is the capital of
France?"}]}
+--- response_headers
+X-AI-Cache-Status: HIT
+! X-AI-Cache-Similarity
+--- response_body_like eval
+qr/1 \+ 1 = 2/
+
+
+
+=== TEST 45: set a semantic route with a custom vector_search index ("myidx")
+--- 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": "/semantic",
+ "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,
+ "layers": ["exact", "semantic"],
+ "semantic": {
+ "similarity_threshold": 0.9,
+ "embedding": {
+ "openai": {
+ "endpoint":
"http://127.0.0.1:6724/v1/embeddings",
+ "model": "text-embedding-3-small",
+ "api_key": "test-key"
+ }
+ },
+ "vector_search": { "redis": { "index": "myidx"
} }
+ }
+ }
+ }
+ }]]
+ )
+ if code >= 300 then ngx.status = code end
+ ngx.say(body)
+ }
+ }
+--- response_body
+passed
+
+
+
+=== TEST 46: custom-index cold request is a MISS (warms L2 under "myidx")
+--- request
+POST /semantic
+{"model":"gpt-4o","messages":[{"role":"user","content":"What is the capital of
France?"}]}
+--- more_headers
+X-AI-Fixture: openai/chat-basic.json
+--- response_headers
+X-AI-Cache-Status: MISS
+--- wait: 0.5
+
+
+
+=== TEST 47: a paraphrase is a semantic HIT served from the custom index
+--- request
+POST /semantic
+{"model":"gpt-4o","messages":[{"role":"user","content":"What's the capital
city of France?"}]}
+--- response_headers_like
+X-AI-Cache-Status: HIT
+X-AI-Cache-Similarity: 0\.92\d\d
+
+
+
+=== TEST 48: L2 docs live under the custom "myidx:l2:" prefix, never the
default "ai-cache:l2:"
+--- config
+ location /t {
+ content_by_lua_block {
+ local redis_util = require("apisix.utils.redis")
+ local red = assert(redis_util.new({
+ redis_host = "127.0.0.1", redis_port = 6379, redis_database =
0 }))
+ local myidx = red:keys("myidx:l2:*")
+ local default = red:keys("ai-cache:l2:*")
+ red:close()
+ ngx.say("myidx-l2=", (myidx and #myidx > 0) and "present" or
"absent")
+ ngx.say("default-l2=", (default and #default > 0) and "present" or
"none")
+ }
+ }
+--- response_body
+myidx-l2=present
+default-l2=none
+
+
+
+=== TEST 49: set a strict route -- similarity_threshold 0.8 (above the real
related-question score)
+--- 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": "/semantic",
+ "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,
+ "layers": ["exact", "semantic"],
+ "semantic": {
+ "similarity_threshold": 0.8,
+ "embedding": {
+ "openai": {
+ "endpoint":
"http://127.0.0.1:6724/v1/embeddings",
+ "model": "text-embedding-3-small",
+ "api_key": "test-key"
+ }
+ },
+ "vector_search": { "redis": { "index":
"idx-thrhi" } }
+ }
+ }
+ }
+ }]]
+ )
+ if code >= 300 then ngx.status = code end
+ ngx.say(body)
+ }
+ }
+--- response_body
+passed
+
+
+
+=== TEST 50: anchor cold request is a MISS (stores the "capital" embedding)
+--- request
+POST /semantic
+{"model":"gpt-4o","messages":[{"role":"user","content":"What is the capital of
France?"}]}
+--- more_headers
+X-AI-Fixture: openai/chat-basic.json
+--- response_headers
+X-AI-Cache-Status: MISS
+--- wait: 0.5
+
+
+
+=== TEST 51: the related question ("largest city", cos 0.706) is a MISS under
the 0.8 threshold
+--- request
+POST /semantic
+{"model":"gpt-4o","messages":[{"role":"user","content":"What is the largest
city in France?"}]}
+--- more_headers
+X-AI-Fixture: openai/chat-basic.json
+--- response_headers
+X-AI-Cache-Status: MISS
+--- wait: 0.3
+
+
+
+=== TEST 52: set the same scenario with a lenient similarity_threshold of 0.6
+--- 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": "/semantic",
+ "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,
+ "layers": ["exact", "semantic"],
+ "semantic": {
+ "similarity_threshold": 0.6,
+ "embedding": {
+ "openai": {
+ "endpoint":
"http://127.0.0.1:6724/v1/embeddings",
+ "model": "text-embedding-3-small",
+ "api_key": "test-key"
+ }
+ },
+ "vector_search": { "redis": { "index":
"idx-thrlo" } }
+ }
+ }
+ }
+ }]]
+ )
+ if code >= 300 then ngx.status = code end
+ ngx.say(body)
+ }
+ }
+--- response_body
+passed
+
+
+
+=== TEST 53: anchor cold request is a MISS (stores the "capital" embedding)
+--- request
+POST /semantic
+{"model":"gpt-4o","messages":[{"role":"user","content":"What is the capital of
France?"}]}
+--- more_headers
+X-AI-Fixture: openai/chat-basic.json
+--- response_headers
+X-AI-Cache-Status: MISS
+--- wait: 0.5
+
+
+
+=== TEST 54: the same related question is a HIT under the 0.6 threshold (real
similarity 0.706)
+--- request
+POST /semantic
+{"model":"gpt-4o","messages":[{"role":"user","content":"What is the largest
city in France?"}]}
+--- response_headers_like
+X-AI-Cache-Status: HIT
+X-AI-Cache-Similarity: 0\.70\d\d
+--- response_body_like eval
+qr/1 \+ 1 = 2/
+
+
+
+=== TEST 55: context_messages returns the messages the embedding does NOT
cover (semantic.lua unit)
+--- config
+ location /t {
+ content_by_lua_block {
+ local semantic = require("apisix.plugins.ai-cache.semantic")
+ local msgs = {
+ { role = "system", content = "a document" },
+ { role = "user", content = "first question" },
+ { role = "assistant", content = "an answer" },
+ { role = "user", content = "the real question" },
+ }
+ -- default match embeds only the last user message; everything else
+ -- is response-determining context that must isolate the partition
+ local ctx = semantic.context_messages(msgs, { message_countback =
1 })
+ local out = {}
+ for _, m in ipairs(ctx) do out[#out + 1] = m.role .. ":" ..
m.content end
+ ngx.say(table.concat(out, ","))
+ -- opting every message INTO the embedding leaves no separate
context
+ local all = semantic.context_messages(msgs,
+ { message_countback = 4, ignore_system_prompts = false,
+ ignore_assistant_prompts = false })
+ ngx.say(#all == 0 and "empty" or "non-empty")
+ }
+ }
+--- response_body
+system:a document,user:first question,assistant:an answer
+empty
+
+
+
+=== TEST 56: set a semantic route for the doc-Q&A regression (default match,
threshold 0.9)
+--- 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": "/semantic",
+ "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,
+ "layers": ["exact", "semantic"],
+ "semantic": {
+ "similarity_threshold": 0.9,
+ "embedding": {
+ "openai": {
+ "endpoint":
"http://127.0.0.1:6724/v1/embeddings",
+ "model": "text-embedding-3-small",
+ "api_key": "test-key"
+ }
+ },
+ "vector_search": { "redis": {} }
+ }
+ }
+ }
+ }]]
+ )
+ if code >= 300 then ngx.status = code end
+ ngx.say(body)
+ }
+ }
+--- response_body
+passed
+
+
+
+=== TEST 57: doc A + a generic question is a cold MISS (warms doc A's
partition)
+--- request
+POST /semantic
+{"model":"gpt-4o","messages":[{"role":"system","content":"Document A: Paris is
the capital of France."},{"role":"user","content":"What is the capital of
France?"}]}
+--- more_headers
+X-AI-Fixture: openai/chat-basic.json
+--- response_headers
+X-AI-Cache-Status: MISS
+--- wait: 0.5
+
+
+
+=== TEST 58: the SAME question under a different document (doc B) is a MISS,
not a cross-context HIT
+--- request
+POST /semantic
+{"model":"gpt-4o","messages":[{"role":"system","content":"Document B: Berlin
is the capital of Germany."},{"role":"user","content":"What is the capital of
France?"}]}
+--- more_headers
+X-AI-Fixture: openai/chat-basic.json
+--- response_headers
+X-AI-Cache-Status: MISS
+--- wait: 0.5
+
+
+
+=== TEST 59: a paraphrase under the SAME document (doc A) still hits L2
(context preserved, wording fuzzy)
+--- request
+POST /semantic
+{"model":"gpt-4o","messages":[{"role":"system","content":"Document A: Paris is
the capital of France."},{"role":"user","content":"What's the capital city of
France?"}]}
+--- response_headers_like
+X-AI-Cache-Status: HIT
+X-AI-Cache-Similarity: 0\.92\d\d
+--- response_body_like eval
+qr/1 \+ 1 = 2/
+
+
+
+=== TEST 60: semantic L2 bypasses a protocol whose canonical form is lossy
(openai-responses)
+--- config
+ location /t {
+ content_by_lua_block {
+ local semantic = require("apisix.plugins.ai-cache.semantic")
+ -- the gate returns before any embedding work, so conf/body are
+ -- never touched; an unsupported protocol must fail open as no-L2
+ local ctx = { ai_client_protocol = "openai-responses" }
+ local vec = semantic.embed_query(nil, ctx, nil)
+ ngx.say(vec == nil and "bypassed" or "engaged")
+ ngx.say(ctx.ai_cache_embedding == nil and "no-embedding" or
"embedded")
+ }
+ }
+--- response_body
+bypassed
+no-embedding
+
+
+
+=== TEST 61: a semantic block without "semantic" in layers is accepted
(inactive), not rejected
+--- config
+ location /t {
+ content_by_lua_block {
+ local plugin = require("apisix.plugins.ai-cache")
+ -- a full, valid semantic block but layers left at the default
+ -- ["exact"]: allowed so the config can be staged/feature-flagged
+ local ok, err = plugin.check_schema({
+ redis_host = "127.0.0.1",
+ semantic = {
+ embedding = { openai = {
+ model = "text-embedding-3-small", api_key = "test-key"
} },
+ vector_search = { redis = {} },
+ },
+ })
+ ngx.say(ok and "passed" or err)
+ }
+ }
+--- response_body
+passed
+
+
+
+=== TEST 62: set a semantic route for the multimodal-bypass regression
(threshold 0.9)
+--- 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": "/semantic",
+ "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,
+ "layers": ["exact", "semantic"],
+ "semantic": {
+ "similarity_threshold": 0.9,
+ "embedding": {
+ "openai": {
+ "endpoint":
"http://127.0.0.1:6724/v1/embeddings",
+ "model": "text-embedding-3-small",
+ "api_key": "test-key"
+ }
+ },
+ "vector_search": { "redis": {} }
+ }
+ }
+ }
+ }]]
+ )
+ if code >= 300 then ngx.status = code end
+ ngx.say(body)
+ }
+ }
+--- response_body
+passed
+
+
+
+=== TEST 63: a text-only prompt warms L2 with the "capital" vector (cold MISS)
+--- request
+POST /semantic
+{"model":"gpt-4o","messages":[{"role":"user","content":"What is the capital of
France?"}]}
+--- more_headers
+X-AI-Fixture: openai/chat-basic.json
+--- response_headers
+X-AI-Cache-Status: MISS
+--- wait: 0.5
+
+
+
+=== TEST 64: the SAME text carried alongside an image block bypasses L2 (a
MISS, not a cross-modal L2 hit)
+--- request
+POST /semantic
+{"model":"gpt-4o","messages":[{"role":"user","content":[{"type":"text","text":"What
is the capital of
France?"},{"type":"image_url","image_url":{"url":"https://example.com/paris.jpg"}}]}]}
+--- more_headers
+X-AI-Fixture: openai/chat-basic.json
+--- response_headers
+X-AI-Cache-Status: MISS
+--- wait: 0.5
+
+
+
+=== TEST 65: ensure_index memo is scoped per Redis target, not by index name
alone (vector-search unit)
+--- config
+ location /t {
+ content_by_lua_block {
+ -- Unit-level on purpose: the bug only shows across two Redis
servers
+ -- (RediSearch forbids FT.CREATE on db!=0) and self-heals
end-to-end.
+ -- Model a second target that lacks the index via FT.DROPINDEX.
+ local redis_util = require("apisix.utils.redis")
+ local vs = require("apisix.plugins.ai-cache.vector-search.redis")
+ local red = assert(redis_util.new({
+ redis_host = "127.0.0.1", redis_port = 6379, redis_database =
0 }))
+ red:flushdb()
+ local index = "tgt-scope:idx:3"
+ -- ensure against target A: the per-worker memo records (A|index)
+ assert(vs.ensure_index(red, "hostA#6379#0", index,
"tgt-scope:l2:", 3))
+ -- drop the index; a different target B has never had it created
+ red[ "FT.DROPINDEX" ](red, index)
+ -- target B must NOT be served by target A's memo entry -- it must
+ -- re-issue FT.CREATE (index-name-only keying would wrongly skip
here)
+ assert(vs.ensure_index(red, "hostB#6379#0", index,
"tgt-scope:l2:", 3))
+ -- proof B's create really happened: a search is a clean miss, not
the
+ -- "no such index" error a memo collision would leave behind
+ local hit, err = vs.knn_search(red, "hostB#6379#0", index,
"deadbeef", { 1, 0, 0 }, 1)
+ ngx.say(hit == nil and not err and "recreated-for-target-B"
+ or ("skipped:" .. (err or "unexpected-hit")))
+ }
+ }
+--- response_body
+recreated-for-target-B