blob: db2a15d93954848d1330ba221e286fc21487bc55 [file]
#
# 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: partition 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 partition deduplicates by the EFFECTIVE request context
-- (provider/model/params/instance) with the message wording removed, so a
-- paraphrase collapses to one partition 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 = { route_id = "1" } }
end
local conf = { cache_key = {} }
local a = key.partition(conf, ctx(), { model = "gpt-4o", temperature = 0.2,
messages = {{ role = "user", content = "how do I return an item?" }} }, nil)
local b = key.partition(conf, ctx(), { model = "gpt-4o", temperature = 0.2,
messages = {{ role = "user", content = "what is the return policy?" }} }, nil)
local c = key.partition(conf, ctx(), { model = "gpt-4o", temperature = 0.9,
messages = {{ role = "user", content = "how do I return an item?" }} }, nil)
assert(a == b, "differently-worded prompts must share one partition")
assert(a ~= c, "a parameter change (temperature) must flip the partition")
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, format = "json" }, 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, format = "json" }, 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, format = "json" }, 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, format = "json" }, 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
=== TEST 66: body_has_nontext detects media/tool-calls and tolerates malformed input (semantic.lua unit)
--- config
location /t {
content_by_lua_block {
local sem = require("apisix.plugins.ai-cache.semantic")
-- plain text content -> no non-text
ngx.say(sem.body_has_nontext({ messages = {{ role = "user", content = "hi" }} }))
-- array of blocks carrying an image -> non-text
ngx.say(sem.body_has_nontext({ messages = {{ role = "user", content = {
{ type = "text", text = "hi" },
{ type = "image_url", image_url = { url = "x" } },
}}}}))
-- content shaped as a single block object (not an array) -> non-text
ngx.say(sem.body_has_nontext({ messages = {{ role = "user",
content = { type = "image_url", image_url = { url = "x" } } }} }))
-- assistant tool_calls are response-determining prompt state -> non-text
ngx.say(sem.body_has_nontext({ messages = {{ role = "assistant", content = ngx.null,
tool_calls = {{ id = "c1", type = "function",
["function"] = { name = "f", arguments = "{}" } }} }} }))
-- legacy function_call -> non-text
ngx.say(sem.body_has_nontext({ messages = {{ role = "assistant", content = ngx.null,
function_call = { name = "f", arguments = "{}" } }} }))
-- empty tool_calls must NOT trigger a bypass
ngx.say(sem.body_has_nontext({ messages = {{ role = "assistant",
content = "ok", tool_calls = {} }} }))
-- scalar / null message items must be skipped, never indexed (no throw)
ngx.say(sem.body_has_nontext({ messages = { 42, ngx.null,
{ role = "user", content = "hi" } } }))
-- no messages at all
ngx.say(sem.body_has_nontext({}))
}
}
--- response_body
false
true
true
true
true
false
false
false