blob: 5419d5ef966b8c2253414d30db0490df8edec159 [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();
add_block_preprocessor(sub {
my ($block) = @_;
if (!defined $block->request) {
$block->set_value("request", "GET /t");
}
my $user_yaml_config = <<_EOC_;
plugins:
- ai-proxy
- ai-cache
_EOC_
if (!defined $block->extra_yaml_config) {
$block->set_value("extra_yaml_config", $user_yaml_config);
}
my $http_config = $block->http_config // <<_EOC_;
server {
listen 6731;
default_type 'application/json';
location / {
content_by_lua_block {
ngx.status = 500
ngx.say([[{"error":{"message":"primary down"}}]])
}
}
}
_EOC_
$block->set_value("http_config", $http_config);
});
run_tests();
__DATA__
=== TEST 1: minimal valid exact-cache configuration
--- config
location /t {
content_by_lua_block {
local plugin = require("apisix.plugins.ai-cache")
local ok, err = plugin.check_schema({
redis_host = "127.0.0.1",
redis_port = 6379,
})
if not ok then
ngx.say(err)
else
ngx.say("passed")
end
}
}
--- response_body
passed
=== TEST 2: reject config missing required redis (policy=redis then-clause)
--- config
location /t {
content_by_lua_block {
local plugin = require("apisix.plugins.ai-cache")
local ok, err = plugin.check_schema({})
if not ok then
ngx.say(err)
else
ngx.say("passed")
end
}
}
--- response_body eval
qr/then clause did not match/
=== TEST 3: reject an out-of-range exact.ttl
--- config
location /t {
content_by_lua_block {
local plugin = require("apisix.plugins.ai-cache")
local ok, err = plugin.check_schema({
redis_host = "127.0.0.1",
exact = { ttl = 0 },
})
if not ok then
ngx.say(err)
else
ngx.say("passed")
end
}
}
--- response_body eval
qr/ttl/
=== TEST 4: flush redis, then set route with ai-proxy + ai-cache (mock upstream)
--- config
location /t {
content_by_lua_block {
require("lib.test_redis").flush_port("127.0.0.1", 6379)
local t = require("lib.test_admin").test
local code, body = t('/apisix/admin/routes/1',
ngx.HTTP_PUT,
[[{
"uri": "/anything",
"plugins": {
"ai-proxy": {
"provider": "openai",
"auth": {
"header": {
"Authorization": "Bearer test-key"
}
},
"options": {
"model": "gpt-4o"
},
"override": {
"endpoint": "http://127.0.0.1:1980"
}
},
"ai-cache": {
"redis_host": "127.0.0.1",
"redis_port": 6379
}
}
}]]
)
if code >= 300 then
ngx.status = code
end
ngx.say(body)
}
}
--- response_body
passed
=== TEST 5: cold request is a cache MISS and is proxied upstream
--- request
POST /anything
{"model":"gpt-4o","messages":[{"role":"user","content":"ai-cache miss unique-prompt-5"}]}
--- more_headers
X-AI-Fixture: openai/chat-basic.json
--- response_headers
X-AI-Cache-Status: MISS
--- response_body_like eval
qr/1 \+ 1 = 2/
--- wait: 0.3
=== TEST 6: identical re-request is a HIT served from cache (upstream not called)
--- request
POST /anything
{"model":"gpt-4o","messages":[{"role":"user","content":"ai-cache miss unique-prompt-5"}]}
--- error_code: 200
--- response_headers_like
X-AI-Cache-Status: HIT
X-AI-Cache-Age: \d+
--- response_body_like eval
qr/1 \+ 1 = 2/
=== TEST 7: fingerprint covers the client request AND the effective instance config (key.lua unit)
--- config
location /t {
content_by_lua_block {
local core = require("apisix.core")
local key = require("apisix.plugins.ai-cache.key")
-- fingerprint() identifies the effective upstream request, so it reads
-- both the client body and the picked instance (provider/options/override).
local function fp(inst, body)
local ctx = { ai_client_protocol = "openai-chat", var = {},
picked_ai_instance = inst }
return key.fingerprint(ctx, body)
end
local inst = { provider = "openai", options = {}, override = {} }
local prompt = { model = "gpt-4o", messages = {{role="user", content="hi"}}, temperature = 0.2 }
local base = fp(inst, prompt)
-- client fields the upstream would see: a change flips the fingerprint
local other_messages = { model = "gpt-4o", messages = {{role="user", content="bye"}} }
local other_model = { model = "gpt-4o-mini", messages = {{role="user", content="hi"}} }
local other_temp = { model = "gpt-4o", messages = {{role="user", content="hi"}}, temperature = 0.9 }
local with_stream = { model = "gpt-4o", messages = {{role="user", content="hi"}}, temperature = 0.2, stream = true }
assert(fp(inst, prompt) == base, "identical request and config must match")
assert(fp(inst, other_messages) ~= base, "different messages")
assert(fp(inst, other_model) ~= base, "different client model")
assert(fp(inst, other_temp) ~= base, "different temperature")
assert(fp(inst, with_stream) ~= base, "the stream flag is folded into the fingerprint (streaming and non-streaming cache separately)")
-- instance config that ai-proxy applies upstream: a change flips it too
local other_provider = { provider = "deepseek", options = {}, override = {} }
local forced_model = { provider = "openai", options = { model = "gpt-4o-mini" }, override = {} }
local server_temp = { provider = "openai", options = { temperature = 0.9 }, override = {} }
local llm_opts = { provider = "openai", options = {}, override = { llm_options = { max_tokens = 16 } } }
local body_override = { provider = "openai", options = {}, override = { request_body = { ["openai-chat"] = { foo = "bar" } } } }
local endpoint_a = { provider = "openai", options = {}, override = { endpoint = "http://host/a" } }
local endpoint_b = { provider = "openai", options = {}, override = { endpoint = "http://host/b" } }
assert(fp(other_provider, prompt) ~= base, "different provider")
assert(fp(forced_model, prompt) ~= base, "different effective model")
assert(fp(server_temp, prompt) ~= base, "different server-side options")
assert(fp(llm_opts, prompt) ~= base, "different override.llm_options")
assert(fp(body_override, prompt) ~= base, "different override.request_body")
assert(fp(endpoint_a, prompt) ~= fp(endpoint_b, prompt), "different override.endpoint")
-- a forced options.model overrides the client model, so the client model stops mattering
assert(fp(forced_model, { messages = prompt.messages, model = "gpt-4o" })
== fp(forced_model, { messages = prompt.messages, model = "zzz" }),
"forced options.model makes the client model irrelevant")
-- an explicit JSON null must encode stably (regression for the rapidjson null path)
local with_null = core.json.decode(
'{"model":"gpt-4o","messages":[{"role":"user","content":"hi"}],"stop":null}')
assert(fp(inst, with_null) == fp(inst, with_null), "null-bearing fingerprint must be stable")
assert(fp(inst, with_null) ~= base, "an explicit null must change the fingerprint")
ngx.say("passed")
}
}
--- response_body
passed
=== TEST 8: non-2xx upstream (no fixture -> 401) is a MISS
--- request
POST /anything
{"model":"gpt-4o","messages":[{"role":"user","content":"non-2xx-test-prompt"}]}
--- error_code: 401
--- response_headers
X-AI-Cache-Status: MISS
--- wait: 0.3
=== TEST 9: same prompt with a valid fixture is still a MISS (the 401 was not cached)
--- request
POST /anything
{"model":"gpt-4o","messages":[{"role":"user","content":"non-2xx-test-prompt"}]}
--- more_headers
X-AI-Fixture: openai/chat-basic.json
--- error_code: 200
--- response_headers
X-AI-Cache-Status: MISS
--- response_body_like eval
qr/1 \+ 1 = 2/
=== TEST 10: set route with a bypass_on header rule
--- config
location /t {
content_by_lua_block {
local t = require("lib.test_admin").test
local code, body = t('/apisix/admin/routes/1',
ngx.HTTP_PUT,
[[{
"uri": "/anything",
"plugins": {
"ai-proxy": {
"provider": "openai",
"auth": { "header": { "Authorization": "Bearer test-key" } },
"options": { "model": "gpt-4o" },
"override": { "endpoint": "http://127.0.0.1:1980" }
},
"ai-cache": {
"redis_host": "127.0.0.1",
"redis_port": 6379,
"bypass_on": [{"header": "X-AI-Cache-Bypass", "equals": "1"}]
}
}
}]]
)
if code >= 300 then
ngx.status = code
end
ngx.say(body)
}
}
--- response_body
passed
=== TEST 11: a matching bypass_on header value is a BYPASS
--- request
POST /anything
{"model":"gpt-4o","messages":[{"role":"user","content":"bypass rule test"}]}
--- more_headers
X-AI-Fixture: openai/chat-basic.json
X-AI-Cache-Bypass: 1
--- response_headers
X-AI-Cache-Status: BYPASS
=== TEST 12: a non-matching bypass_on header value does not bypass (normal MISS)
--- request
POST /anything
{"model":"gpt-4o","messages":[{"role":"user","content":"bypass-nonmatch-test"}]}
--- more_headers
X-AI-Fixture: openai/chat-basic.json
X-AI-Cache-Bypass: 0
--- response_headers
X-AI-Cache-Status: MISS
=== TEST 13: set route with multiple bypass_on rules
--- config
location /t {
content_by_lua_block {
local t = require("lib.test_admin").test
local code, body = t('/apisix/admin/routes/1',
ngx.HTTP_PUT,
[[{
"uri": "/anything",
"plugins": {
"ai-proxy": {
"provider": "openai",
"auth": { "header": { "Authorization": "Bearer test-key" } },
"options": { "model": "gpt-4o" },
"override": { "endpoint": "http://127.0.0.1:1980" }
},
"ai-cache": {
"redis_host": "127.0.0.1",
"redis_port": 6379,
"bypass_on": [
{"header": "X-AI-Cache-Bypass", "equals": "1"},
{"header": "X-Debug", "equals": "on"}
]
}
}
}]]
)
if code >= 300 then
ngx.status = code
end
ngx.say(body)
}
}
--- response_body
passed
=== TEST 14: any matching bypass_on rule triggers a BYPASS (second rule matches)
--- request
POST /anything
{"model":"gpt-4o","messages":[{"role":"user","content":"any-rule-bypass-test"}]}
--- more_headers
X-AI-Fixture: openai/chat-basic.json
X-Debug: on
--- response_headers
X-AI-Cache-Status: BYPASS
=== TEST 15: set route with a tiny max_cache_body_size
--- config
location /t {
content_by_lua_block {
local t = require("lib.test_admin").test
local code, body = t('/apisix/admin/routes/1',
ngx.HTTP_PUT,
[[{
"uri": "/anything",
"plugins": {
"ai-proxy": {
"provider": "openai",
"auth": { "header": { "Authorization": "Bearer test-key" } },
"options": { "model": "gpt-4o" },
"override": { "endpoint": "http://127.0.0.1:1980" }
},
"ai-cache": {
"redis_host": "127.0.0.1",
"redis_port": 6379,
"max_cache_body_size": 10
}
}
}]]
)
if code >= 300 then
ngx.status = code
end
ngx.say(body)
}
}
--- response_body
passed
=== TEST 16: cold request (response exceeds max_cache_body_size) is a MISS
--- request
POST /anything
{"model":"gpt-4o","messages":[{"role":"user","content":"body-size-test"}]}
--- more_headers
X-AI-Fixture: openai/chat-basic.json
--- response_headers
X-AI-Cache-Status: MISS
--- wait: 0.3
=== TEST 17: same prompt is still a MISS (oversized response was not cached)
--- request
POST /anything
{"model":"gpt-4o","messages":[{"role":"user","content":"body-size-test"}]}
--- more_headers
X-AI-Fixture: openai/chat-basic.json
--- response_headers
X-AI-Cache-Status: MISS
=== TEST 18: set route isolating the cache by a request variable
--- config
location /t {
content_by_lua_block {
local t = require("lib.test_admin").test
local code, body = t('/apisix/admin/routes/1',
ngx.HTTP_PUT,
[[{
"uri": "/anything",
"plugins": {
"ai-proxy": {
"provider": "openai",
"auth": { "header": { "Authorization": "Bearer test-key" } },
"options": { "model": "gpt-4o" },
"override": { "endpoint": "http://127.0.0.1:1980" }
},
"ai-cache": {
"redis_host": "127.0.0.1",
"redis_port": 6379,
"cache_key": { "include_vars": ["http_x_tenant"] }
}
}
}]]
)
if code >= 300 then
ngx.status = code
end
ngx.say(body)
}
}
--- response_body
passed
=== TEST 19: tenant alpha cold request is a MISS (warms scope=alpha)
--- request
POST /anything
{"model":"gpt-4o","messages":[{"role":"user","content":"scope isolation test"}]}
--- more_headers
X-AI-Fixture: openai/chat-basic.json
X-Tenant: alpha
--- response_headers
X-AI-Cache-Status: MISS
--- wait: 0.3
=== TEST 20: same prompt, tenant beta is a MISS (not shared with alpha)
--- request
POST /anything
{"model":"gpt-4o","messages":[{"role":"user","content":"scope isolation test"}]}
--- more_headers
X-AI-Fixture: openai/chat-basic.json
X-Tenant: beta
--- response_headers
X-AI-Cache-Status: MISS
=== TEST 21: same prompt, tenant alpha is a HIT (its own scope persisted)
--- request
POST /anything
{"model":"gpt-4o","messages":[{"role":"user","content":"scope isolation test"}]}
--- more_headers
X-Tenant: alpha
--- error_code: 200
--- response_headers
X-AI-Cache-Status: HIT
=== TEST 22: set route with a 1-second exact ttl
--- config
location /t {
content_by_lua_block {
local t = require("lib.test_admin").test
local code, body = t('/apisix/admin/routes/1',
ngx.HTTP_PUT,
[[{
"uri": "/anything",
"plugins": {
"ai-proxy": {
"provider": "openai",
"auth": { "header": { "Authorization": "Bearer test-key" } },
"options": { "model": "gpt-4o" },
"override": { "endpoint": "http://127.0.0.1:1980" }
},
"ai-cache": {
"redis_host": "127.0.0.1",
"redis_port": 6379,
"exact": { "ttl": 1 }
}
}
}]]
)
if code >= 300 then
ngx.status = code
end
ngx.say(body)
}
}
--- response_body
passed
=== TEST 23: cold request is a MISS (cached with ttl=1), then wait past the ttl
--- request
POST /anything
{"model":"gpt-4o","messages":[{"role":"user","content":"ttl-expiry-test"}]}
--- more_headers
X-AI-Fixture: openai/chat-basic.json
--- response_headers
X-AI-Cache-Status: MISS
--- wait: 2
=== TEST 24: same prompt is a MISS again (entry expired)
--- request
POST /anything
{"model":"gpt-4o","messages":[{"role":"user","content":"ttl-expiry-test"}]}
--- more_headers
X-AI-Fixture: openai/chat-basic.json
--- response_headers
X-AI-Cache-Status: MISS
=== TEST 25: set an anthropic-messages route (cross-protocol)
--- config
location /t {
content_by_lua_block {
local t = require("lib.test_admin").test
local code, body = t('/apisix/admin/routes/2',
ngx.HTTP_PUT,
[[{
"uri": "/v1/messages",
"plugins": {
"ai-proxy": {
"provider": "anthropic",
"auth": { "header": { "x-api-key": "test-key" } },
"options": { "model": "claude-3-5-sonnet-20241022" },
"override": { "endpoint": "http://127.0.0.1:1980" }
},
"ai-cache": {
"redis_host": "127.0.0.1",
"redis_port": 6379
}
}
}]]
)
if code >= 300 then
ngx.status = code
end
ngx.say(body)
}
}
--- response_body
passed
=== TEST 26: anthropic cold request is a MISS
--- request
POST /v1/messages
{"model":"claude-3-5-sonnet-20241022","messages":[{"role":"user","content":"cross-protocol test"}],"max_tokens":100}
--- more_headers
X-AI-Fixture: anthropic/messages-basic.json
--- response_headers
X-AI-Cache-Status: MISS
--- wait: 0.3
=== TEST 27: identical anthropic re-request is a HIT (upstream not called)
--- request
POST /v1/messages
{"model":"claude-3-5-sonnet-20241022","messages":[{"role":"user","content":"cross-protocol test"}],"max_tokens":100}
--- error_code: 200
--- response_headers
X-AI-Cache-Status: HIT
=== TEST 28: set route whose redis is unreachable
--- config
location /t {
content_by_lua_block {
local t = require("lib.test_admin").test
local code, body = t('/apisix/admin/routes/1',
ngx.HTTP_PUT,
[[{
"uri": "/anything",
"plugins": {
"ai-proxy": {
"provider": "openai",
"auth": { "header": { "Authorization": "Bearer test-key" } },
"options": { "model": "gpt-4o" },
"override": { "endpoint": "http://127.0.0.1:1980" }
},
"ai-cache": {
"redis_host": "127.0.0.1",
"redis_port": 6390,
"redis_timeout": 200
}
}
}]]
)
if code >= 300 then
ngx.status = code
end
ngx.say(body)
}
}
--- response_body
passed
=== TEST 29: redis unreachable fails open (request still proxied as MISS, no 5xx)
--- request
POST /anything
{"model":"gpt-4o","messages":[{"role":"user","content":"redis-down failopen"}]}
--- more_headers
X-AI-Fixture: openai/chat-basic.json
--- error_code: 200
--- response_headers
X-AI-Cache-Status: MISS
--- response_body_like eval
qr/1 \+ 1 = 2/
--- error_log
ai-cache: L1 lookup failed, fail-open as MISS
=== TEST 30: set route with cache_headers disabled
--- config
location /t {
content_by_lua_block {
local t = require("lib.test_admin").test
local code, body = t('/apisix/admin/routes/1',
ngx.HTTP_PUT,
[[{
"uri": "/anything",
"plugins": {
"ai-proxy": {
"provider": "openai",
"auth": { "header": { "Authorization": "Bearer test-key" } },
"options": { "model": "gpt-4o" },
"override": { "endpoint": "http://127.0.0.1:1980" }
},
"ai-cache": {
"redis_host": "127.0.0.1",
"redis_port": 6379,
"cache_headers": false
}
}
}]]
)
if code >= 300 then
ngx.status = code
end
ngx.say(body)
}
}
--- response_body
passed
=== TEST 31: cache_headers=false suppresses the X-AI-Cache-* headers
--- request
POST /anything
{"model":"gpt-4o","messages":[{"role":"user","content":"cache-headers-off-test"}]}
--- more_headers
X-AI-Fixture: openai/chat-basic.json
--- error_code: 200
--- response_headers
! X-AI-Cache-Status
! X-AI-Cache-Age
--- response_body_like eval
qr/1 \+ 1 = 2/
=== TEST 32: set a default ai-proxy + ai-cache route (for status-code tests)
--- config
location /t {
content_by_lua_block {
local t = require("lib.test_admin").test
local code, body = t('/apisix/admin/routes/1',
ngx.HTTP_PUT,
[[{
"uri": "/anything",
"plugins": {
"ai-proxy": {
"provider": "openai",
"auth": { "header": { "Authorization": "Bearer test-key" } },
"options": { "model": "gpt-4o" },
"override": { "endpoint": "http://127.0.0.1:1980" }
},
"ai-cache": {
"redis_host": "127.0.0.1",
"redis_port": 6379
}
}
}]]
)
if code >= 300 then
ngx.status = code
end
ngx.say(body)
}
}
--- response_body
passed
=== TEST 33: a 2xx that is not 200 (201) is a MISS and is proxied through
--- request
POST /anything
{"model":"gpt-4o","messages":[{"role":"user","content":"status-201-test-prompt"}]}
--- more_headers
X-AI-Fixture: openai/chat-basic.json
X-AI-Fixture-Status: 201
--- error_code: 201
--- response_headers
X-AI-Cache-Status: MISS
--- wait: 0.3
=== TEST 34: same prompt with a 200 fixture is still a MISS (the 201 was not cached)
--- request
POST /anything
{"model":"gpt-4o","messages":[{"role":"user","content":"status-201-test-prompt"}]}
--- more_headers
X-AI-Fixture: openai/chat-basic.json
--- error_code: 200
--- response_headers
X-AI-Cache-Status: MISS
--- response_body_like eval
qr/1 \+ 1 = 2/
=== TEST 35: set two openai routes (same model, default scope) sharing one Redis
--- config
location /t {
content_by_lua_block {
local t = require("lib.test_admin").test
local code, body = t('/apisix/admin/routes/1',
ngx.HTTP_PUT,
[[{
"uri": "/anything",
"plugins": {
"ai-proxy": {
"provider": "openai",
"auth": { "header": { "Authorization": "Bearer test-key" } },
"options": { "model": "gpt-4o" },
"override": { "endpoint": "http://127.0.0.1:1980" }
},
"ai-cache": {
"redis_host": "127.0.0.1",
"redis_port": 6379
}
}
}]]
)
if code >= 300 then
ngx.status = code
end
ngx.say(body)
code, body = t('/apisix/admin/routes/2',
ngx.HTTP_PUT,
[[{
"uri": "/cache-route-b",
"plugins": {
"ai-proxy": {
"provider": "openai",
"auth": { "header": { "Authorization": "Bearer test-key" } },
"options": { "model": "gpt-4o" },
"override": { "endpoint": "http://127.0.0.1:1980" }
},
"ai-cache": {
"redis_host": "127.0.0.1",
"redis_port": 6379
}
}
}]]
)
if code >= 300 then
ngx.status = code
end
ngx.say(body)
}
}
--- response_body
passed
passed
=== TEST 36: route 1 cold request is a MISS (warms scope=route=1)
--- request
POST /anything
{"model":"gpt-4o","messages":[{"role":"user","content":"cross-route isolation test"}]}
--- more_headers
X-AI-Fixture: openai/chat-basic.json
--- response_headers
X-AI-Cache-Status: MISS
--- wait: 0.3
=== TEST 37: same prompt on route 2 is a MISS (not shared with route 1 by default)
--- request
POST /cache-route-b
{"model":"gpt-4o","messages":[{"role":"user","content":"cross-route isolation test"}]}
--- more_headers
X-AI-Fixture: openai/chat-basic.json
--- response_headers
X-AI-Cache-Status: MISS
=== TEST 38: same prompt on route 1 is a HIT (its own per-route scope persisted)
--- request
POST /anything
{"model":"gpt-4o","messages":[{"role":"user","content":"cross-route isolation test"}]}
--- error_code: 200
--- response_headers
X-AI-Cache-Status: HIT
=== TEST 39: set both routes with share_across_routes enabled
--- config
location /t {
content_by_lua_block {
local t = require("lib.test_admin").test
local code, body = t('/apisix/admin/routes/1',
ngx.HTTP_PUT,
[[{
"uri": "/anything",
"plugins": {
"ai-proxy": {
"provider": "openai",
"auth": { "header": { "Authorization": "Bearer test-key" } },
"options": { "model": "gpt-4o" },
"override": { "endpoint": "http://127.0.0.1:1980" }
},
"ai-cache": {
"redis_host": "127.0.0.1",
"redis_port": 6379,
"cache_key": { "share_across_routes": true }
}
}
}]]
)
if code >= 300 then
ngx.status = code
end
ngx.say(body)
code, body = t('/apisix/admin/routes/2',
ngx.HTTP_PUT,
[[{
"uri": "/cache-route-b",
"plugins": {
"ai-proxy": {
"provider": "openai",
"auth": { "header": { "Authorization": "Bearer test-key" } },
"options": { "model": "gpt-4o" },
"override": { "endpoint": "http://127.0.0.1:1980" }
},
"ai-cache": {
"redis_host": "127.0.0.1",
"redis_port": 6379,
"cache_key": { "share_across_routes": true }
}
}
}]]
)
if code >= 300 then
ngx.status = code
end
ngx.say(body)
}
}
--- response_body
passed
passed
=== TEST 40: route 1 cold request is a MISS (warms the shared scope)
--- request
POST /anything
{"model":"gpt-4o","messages":[{"role":"user","content":"cross-route share test"}]}
--- more_headers
X-AI-Fixture: openai/chat-basic.json
--- response_headers
X-AI-Cache-Status: MISS
--- wait: 0.3
=== TEST 41: same prompt on route 2 is a HIT (cache shared across routes)
--- request
POST /cache-route-b
{"model":"gpt-4o","messages":[{"role":"user","content":"cross-route share test"}]}
--- error_code: 200
--- response_headers
X-AI-Cache-Status: HIT
=== TEST 42: route with ai-cache but NO ai-proxy in front
--- config
location /t {
content_by_lua_block {
local t = require("lib.test_admin").test
local code, body = t('/apisix/admin/routes/1',
ngx.HTTP_PUT,
[[{
"uri": "/v1/chat/completions",
"upstream": {
"type": "roundrobin",
"nodes": { "127.0.0.1:1980": 1 }
},
"plugins": {
"ai-cache": {
"redis_host": "127.0.0.1",
"redis_port": 6379
}
}
}]]
)
if code >= 300 then
ngx.status = code
end
ngx.say(body)
}
}
--- response_body
passed
=== TEST 43: a request that never passed through ai-proxy is bypassed, not cached
--- request
POST /v1/chat/completions
{"model":"gpt-4o","messages":[{"role":"user","content":"no ai-proxy guard test"}]}
--- more_headers
X-AI-Fixture: openai/chat-basic.json
--- error_code: 200
--- response_headers
X-AI-Cache-Status: BYPASS
=== TEST 44: route with ai-cache fail_mode=error and NO ai-proxy
--- config
location /t {
content_by_lua_block {
local t = require("lib.test_admin").test
local code, body = t('/apisix/admin/routes/1',
ngx.HTTP_PUT,
[[{
"uri": "/v1/chat/completions",
"upstream": {
"type": "roundrobin",
"nodes": { "127.0.0.1:1980": 1 }
},
"plugins": {
"ai-cache": {
"redis_host": "127.0.0.1",
"redis_port": 6379,
"fail_mode": "error"
}
}
}]]
)
if code >= 300 then
ngx.status = code
end
ngx.say(body)
}
}
--- response_body
passed
=== TEST 45: fail_mode=error rejects a request that bypassed the AI proxy
--- request
POST /v1/chat/completions
{"model":"gpt-4o","messages":[{"role":"user","content":"fail_mode error guard test"}]}
--- more_headers
X-AI-Fixture: openai/chat-basic.json
--- error_code: 500
--- response_body_like eval
qr/must be used with the ai-proxy/
=== TEST 46: flush redis, then set one ai-proxy-multi route with two instances
--- extra_yaml_config
plugins:
- ai-proxy-multi
- ai-cache
--- config
location /t {
content_by_lua_block {
require("lib.test_redis").flush_port("127.0.0.1", 6379)
local t = require("lib.test_admin").test
local code, body = t('/apisix/admin/routes/1',
ngx.HTTP_PUT,
[[{
"uri": "/multi",
"plugins": {
"ai-proxy-multi": {
"instances": [
{
"name": "instance-gpt4o",
"provider": "openai",
"weight": 1,
"auth": { "header": { "Authorization": "Bearer test-key" } },
"options": { "model": "gpt-4o" },
"override": { "endpoint": "http://127.0.0.1:1980" }
},
{
"name": "instance-gpt4o-mini",
"provider": "openai",
"weight": 1,
"auth": { "header": { "Authorization": "Bearer test-key" } },
"options": { "model": "gpt-4o-mini" },
"override": { "endpoint": "http://127.0.0.1:1980" }
}
]
},
"ai-cache": {
"redis_host": "127.0.0.1",
"redis_port": 6379
}
}
}]]
)
if code >= 300 then
ngx.status = code
end
ngx.say(body)
}
}
--- response_body
passed
=== TEST 47: round-robin alternates instances, so each one caches independently
--- extra_yaml_config
plugins:
- ai-proxy-multi
- ai-cache
--- pipelined_requests eval
[
"POST /multi\n" . '{"model":"gpt-4o","messages":[{"role":"user","content":"multi-instance isolation"}]}',
"POST /multi\n" . '{"model":"gpt-4o","messages":[{"role":"user","content":"multi-instance isolation"}]}',
"POST /multi\n" . '{"model":"gpt-4o","messages":[{"role":"user","content":"multi-instance isolation"}]}',
"POST /multi\n" . '{"model":"gpt-4o","messages":[{"role":"user","content":"multi-instance isolation"}]}',
]
--- more_headers
X-AI-Fixture: openai/chat-basic.json
--- response_headers eval
[
"X-AI-Cache-Status: MISS",
"X-AI-Cache-Status: MISS",
"X-AI-Cache-Status: HIT",
"X-AI-Cache-Status: HIT",
]
=== TEST 48: flush redis, then two plain ai-proxy routes, same provider, different
options.model, shared cache
--- config
location /t {
content_by_lua_block {
require("lib.test_redis").flush_port("127.0.0.1", 6379)
local t = require("lib.test_admin").test
local code, body = t('/apisix/admin/routes/1',
ngx.HTTP_PUT,
[[{
"uri": "/anything",
"plugins": {
"ai-proxy": {
"provider": "openai",
"auth": { "header": { "Authorization": "Bearer test-key" } },
"options": { "model": "gpt-4o" },
"override": { "endpoint": "http://127.0.0.1:1980" }
},
"ai-cache": {
"redis_host": "127.0.0.1",
"redis_port": 6379,
"cache_key": { "share_across_routes": true }
}
}
}]]
)
if code >= 300 then
ngx.status = code
end
ngx.say(body)
code, body = t('/apisix/admin/routes/2',
ngx.HTTP_PUT,
[[{
"uri": "/cache-route-b",
"plugins": {
"ai-proxy": {
"provider": "openai",
"auth": { "header": { "Authorization": "Bearer test-key" } },
"options": { "model": "gpt-4o-mini" },
"override": { "endpoint": "http://127.0.0.1:1980" }
},
"ai-cache": {
"redis_host": "127.0.0.1",
"redis_port": 6379,
"cache_key": { "share_across_routes": true }
}
}
}]]
)
if code >= 300 then
ngx.status = code
end
ngx.say(body)
}
}
--- response_body
passed
passed
=== TEST 49: route 1 cold request is a MISS (warms the shared scope under gpt-4o)
--- request
POST /anything
{"model":"gpt-4o","messages":[{"role":"user","content":"cross-model share test"}]}
--- more_headers
X-AI-Fixture: openai/chat-basic.json
--- response_headers
X-AI-Cache-Status: MISS
--- wait: 0.3
=== TEST 50: identical request on route 2 is a MISS, not a HIT (different effective
model is not shared even with share_across_routes)
--- request
POST /cache-route-b
{"model":"gpt-4o","messages":[{"role":"user","content":"cross-model share test"}]}
--- more_headers
X-AI-Fixture: openai/chat-basic.json
--- response_headers
X-AI-Cache-Status: MISS
=== TEST 51: flush redis, then two plain ai-proxy routes, same provider and same
options.model, but different server-side options (temperature), shared cache
--- config
location /t {
content_by_lua_block {
require("lib.test_redis").flush_port("127.0.0.1", 6379)
local t = require("lib.test_admin").test
local code, body = t('/apisix/admin/routes/1',
ngx.HTTP_PUT,
[[{
"uri": "/anything",
"plugins": {
"ai-proxy": {
"provider": "openai",
"auth": { "header": { "Authorization": "Bearer test-key" } },
"options": { "model": "gpt-4o", "temperature": 0.2 },
"override": { "endpoint": "http://127.0.0.1:1980" }
},
"ai-cache": {
"redis_host": "127.0.0.1",
"redis_port": 6379,
"cache_key": { "share_across_routes": true }
}
}
}]]
)
if code >= 300 then
ngx.status = code
end
ngx.say(body)
code, body = t('/apisix/admin/routes/2',
ngx.HTTP_PUT,
[[{
"uri": "/cache-route-b",
"plugins": {
"ai-proxy": {
"provider": "openai",
"auth": { "header": { "Authorization": "Bearer test-key" } },
"options": { "model": "gpt-4o", "temperature": 0.8 },
"override": { "endpoint": "http://127.0.0.1:1980" }
},
"ai-cache": {
"redis_host": "127.0.0.1",
"redis_port": 6379,
"cache_key": { "share_across_routes": true }
}
}
}]]
)
if code >= 300 then
ngx.status = code
end
ngx.say(body)
}
}
--- response_body
passed
passed
=== TEST 52: route 1 cold request is a MISS (warms the shared scope under temperature=0.2)
--- request
POST /anything
{"model":"gpt-4o","messages":[{"role":"user","content":"cross-options share test"}]}
--- more_headers
X-AI-Fixture: openai/chat-basic.json
--- response_headers
X-AI-Cache-Status: MISS
--- wait: 0.3
=== TEST 53: identical client body on route 2 is a MISS, not a HIT (different
server-side options are not shared even with share_across_routes)
--- request
POST /cache-route-b
{"model":"gpt-4o","messages":[{"role":"user","content":"cross-options share test"}]}
--- more_headers
X-AI-Fixture: openai/chat-basic.json
--- response_headers
X-AI-Cache-Status: MISS
=== TEST 54: ai-proxy-multi route whose higher-priority instance always 5xxs and
falls back to a healthy instance (different effective endpoint)
--- extra_yaml_config
plugins:
- ai-proxy-multi
- ai-cache
--- config
location /t {
content_by_lua_block {
require("lib.test_redis").flush_port("127.0.0.1", 6379)
local t = require("lib.test_admin").test
local code, body = t('/apisix/admin/routes/1',
ngx.HTTP_PUT,
[[{
"uri": "/fallback",
"plugins": {
"ai-proxy-multi": {
"fallback_strategy": ["http_5xx"],
"balancer": { "algorithm": "roundrobin" },
"instances": [
{
"name": "primary-fail",
"provider": "openai",
"weight": 1,
"priority": 2,
"auth": { "header": { "Authorization": "Bearer test-key" } },
"options": { "model": "gpt-4o" },
"override": { "endpoint": "http://127.0.0.1:6731" }
},
{
"name": "backup-ok",
"provider": "openai",
"weight": 1,
"priority": 1,
"auth": { "header": { "Authorization": "Bearer test-key" } },
"options": { "model": "gpt-4o" },
"override": { "endpoint": "http://127.0.0.1:1980" }
}
]
},
"ai-cache": {
"redis_host": "127.0.0.1",
"redis_port": 6379
}
}
}]]
)
if code >= 300 then
ngx.status = code
end
ngx.say(body)
}
}
--- response_body
passed
=== TEST 55: first request falls back to the healthy instance and is a MISS
--- extra_yaml_config
plugins:
- ai-proxy-multi
- ai-cache
--- request
POST /fallback
{"model":"gpt-4o","messages":[{"role":"user","content":"fallback poison test"}]}
--- more_headers
X-AI-Fixture: openai/chat-basic.json
--- error_code: 200
--- response_headers
X-AI-Cache-Status: MISS
--- wait: 0.3
=== TEST 56: identical request is STILL a MISS -- the fallback instance's response
must not be cached under the originally-picked instance's key
--- extra_yaml_config
plugins:
- ai-proxy-multi
- ai-cache
--- request
POST /fallback
{"model":"gpt-4o","messages":[{"role":"user","content":"fallback poison test"}]}
--- more_headers
X-AI-Fixture: openai/chat-basic.json
--- error_code: 200
--- response_headers
X-AI-Cache-Status: MISS
=== TEST 57: scope() escapes include_vars values so a tenant cannot forge another's scope (key.lua unit)
--- config
location /t {
content_by_lua_block {
local key = require("apisix.plugins.ai-cache.key")
-- include_vars values are request-controlled (e.g. headers), so they
-- must not be able to inject the ":"/"=" scope separators and shift
-- field boundaries into another tenant's scope.
local conf = { cache_key = { share_across_routes = true,
include_vars = { "http_x_a", "http_x_b" } } }
local function key_for(a, b)
return key.build(conf, { var = { http_x_a = a, http_x_b = b } }, "fp")
end
-- baseline: identical values share a key, and plain values stay greppable
assert(key_for("acme", "us") == key_for("acme", "us"),
"identical scope values must produce the same key")
assert(key_for("acme", "us"):find("http_x_a=acme", 1, true),
"plain values must stay readable in the key")
-- two tenants whose raw "http_x_a=<a>:http_x_b=<b>" join both collapse
-- to "http_x_a=x:http_x_b=yZ:http_x_b=" unless the values are escaped
local tenant1 = key_for("x:http_x_b=yZ", "")
local tenant2 = key_for("x", "yZ:http_x_b=")
assert(tenant1 ~= tenant2,
"separator-injecting values must not collide into one scope")
ngx.say("passed")
}
}
--- response_body
passed