This is an automated email from the ASF dual-hosted git repository.
shreemaan-abhishek 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 f0bb251a9 fix(ai-cache): key the passthrough protocol on the client
method, path and query (#13887)
f0bb251a9 is described below
commit f0bb251a97652bb655b03e39e81fda1a3e1e93b2
Author: Shreemaan Abhishek <[email protected]>
AuthorDate: Tue Sep 1 18:56:02 2026 +0800
fix(ai-cache): key the passthrough protocol on the client method, path and
query (#13887)
---
apisix/plugins/ai-cache/key.lua | 22 +++-
docs/en/latest/plugins/ai-cache.md | 2 +
docs/zh/latest/plugins/ai-cache.md | 2 +
t/plugin/ai-cache.t | 214 +++++++++++++++++++++++++++++++++++++
4 files changed, 235 insertions(+), 5 deletions(-)
diff --git a/apisix/plugins/ai-cache/key.lua b/apisix/plugins/ai-cache/key.lua
index 9c6542d28..1d1594388 100644
--- a/apisix/plugins/ai-cache/key.lua
+++ b/apisix/plugins/ai-cache/key.lua
@@ -69,12 +69,24 @@ local function build_repr(ctx, body, messages)
local proto = ctx.ai_client_protocol and
protocols.get(ctx.ai_client_protocol)
params.stream = (proto and proto.is_streaming(body)) == true
+ local client = {
+ protocol = ctx.ai_client_protocol or "",
+ messages = messages,
+ params = params,
+ }
+ -- passthrough proxies the client's method, path and query string verbatim
+ -- (see ai-proxy/base.lua), so under it they select the upstream endpoint
+ -- and are response-determining.
+ if ctx.ai_client_protocol == "passthrough" then
+ -- live method: ctx.var.request_method is cached at route match, before
+ -- a proxy-rewrite method change that the upstream request carries.
+ client.method = core.request.get_method()
+ client.uri = ctx.var.uri
+ client.args = ctx.var.args
+ end
+
return {
- client = {
- protocol = ctx.ai_client_protocol or "",
- messages = messages,
- params = params,
- },
+ client = client,
effective = {
provider = inst.provider,
-- effective model precedence mirrors ai-proxy/base.lua exactly:
diff --git a/docs/en/latest/plugins/ai-cache.md
b/docs/en/latest/plugins/ai-cache.md
index 907b8d177..3443f86b8 100644
--- a/docs/en/latest/plugins/ai-cache.md
+++ b/docs/en/latest/plugins/ai-cache.md
@@ -61,6 +61,8 @@ By default the cache is isolated per route, so two routes
never serve each other
Even with `cache_key.share_across_routes` enabled, the cache key identifies
the *effective* upstream request — the request `ai-proxy` actually sends after
applying the AI instance's `provider`, `options` (model, temperature, and other
model parameters) and `override`. Routes that would call the model differently
therefore keep separate cache entries, so one route's response is never served
for another.
+For the `passthrough` protocol, `ai-proxy` forwards the client's request
method, path and query string verbatim, so the key includes them too: the same
body sent to two different upstream paths, or with different query parameters,
keeps separate entries.
+
:::
## Attributes
diff --git a/docs/zh/latest/plugins/ai-cache.md
b/docs/zh/latest/plugins/ai-cache.md
index 5c9e51234..db4d50fc2 100644
--- a/docs/zh/latest/plugins/ai-cache.md
+++ b/docs/zh/latest/plugins/ai-cache.md
@@ -61,6 +61,8 @@ import TabItem from '@theme/TabItem';
即使开启 `cache_key.share_across_routes`,来自不同上游模型或 provider
的响应也会分别存储在各自的缓存条目中,因此某个模型的响应绝不会被返回给另一个模型。
+对于 `passthrough` 协议,`ai-proxy`
会原样转发客户端的请求方法、路径和查询字符串,因此缓存键也会包含它们:相同的请求体发送到两个不同的上游路径,或携带不同的查询参数时,会分别保存为独立的缓存条目。
+
:::
## 属性
diff --git a/t/plugin/ai-cache.t b/t/plugin/ai-cache.t
index 5419d5ef9..d87c2d3a7 100644
--- a/t/plugin/ai-cache.t
+++ b/t/plugin/ai-cache.t
@@ -1446,3 +1446,217 @@ X-AI-Cache-Status: MISS
}
--- response_body
passed
+
+
+
+=== TEST 58: passthrough folds the client method, path and query into the
fingerprint (key.lua unit)
+--- config
+ location /t {
+ content_by_lua_block {
+ local key = require("apisix.plugins.ai-cache.key")
+
+ local inst = { provider = "openai", options = {}, override = {} }
+ local body = { model = "dall-e-3", prompt = "otter" }
+
+ local function fp(protocol, var)
+ local ctx = { ai_client_protocol = protocol, var = var,
+ picked_ai_instance = inst }
+ return key.fingerprint(ctx, body)
+ end
+
+ local base = { uri = "/v1/images/generations", args = nil }
+ local pt = fp("passthrough", base)
+
+ assert(fp("passthrough", base) == pt, "identical passthrough
request must match")
+ assert(fp("passthrough", { uri = "/v1/images/edits" }) ~= pt,
"different path")
+ assert(fp("passthrough", { uri = "/v1/images/generations",
+ args = "api-version=2024-02-01" }) ~=
pt,
+ "different query string")
+
+ -- the method is read live from the request (a rewrite changes
it), so
+ -- flip it the way proxy-rewrite does rather than through ctx.var
+ ngx.req.set_method(ngx.HTTP_POST)
+ local posted = fp("passthrough", base)
+ assert(posted ~= pt, "different method")
+ ngx.req.set_method(ngx.HTTP_GET)
+ assert(fp("passthrough", base) == pt, "method restored")
+
+ -- other protocols build a fixed upstream request from the body
alone,
+ -- so the client path stays out of their fingerprint
+ local chat_ctx = { ai_client_protocol = "openai-chat", var = { uri
= "/a" },
+ picked_ai_instance = inst }
+ local chat_body = { model = "gpt-4o", messages = {{ role = "user",
content = "hi" }} }
+ local chat_fp = key.fingerprint(chat_ctx, chat_body)
+ chat_ctx.var = { uri = "/b" }
+ assert(key.fingerprint(chat_ctx, chat_body) == chat_fp,
+ "non-passthrough fingerprint ignores the client path")
+
+ ngx.say("passed")
+ }
+ }
+--- response_body
+passed
+
+
+
+=== TEST 59: wildcard passthrough route: ai-proxy without an endpoint path
forwards the client URI
+--- 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": "/v1/*",
+ "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
+ }
+ }
+ }]]
+ )
+ if code >= 300 then
+ ngx.status = code
+ end
+ ngx.say(body)
+ }
+ }
+--- response_body
+passed
+
+
+
+=== TEST 60: passthrough body to /v1/images/generations is a MISS and is
proxied
+--- request
+POST /v1/images/generations
+{"model":"dall-e-3","prompt":"ai-cache passthrough same body"}
+--- more_headers
+X-AI-Fixture: openai/images-generation.json
+--- response_headers
+X-AI-Cache-Status: MISS
+--- response_body_like eval
+qr/baby sea otter/
+--- wait: 0.3
+
+
+
+=== TEST 61: the SAME body to a different upstream path is a MISS, not the
cached images response
+--- request
+POST /v1/chat/completions
+{"model":"dall-e-3","prompt":"ai-cache passthrough same body"}
+--- 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 62: the SAME body and path with a different query string is a MISS
+--- request
+POST /v1/images/generations?api-version=2024-02-01
+{"model":"dall-e-3","prompt":"ai-cache passthrough same body"}
+--- more_headers
+X-AI-Fixture: openai/images-generation.json
+--- response_headers
+X-AI-Cache-Status: MISS
+--- response_body_like eval
+qr/baby sea otter/
+--- wait: 0.3
+
+
+
+=== TEST 63: repeating the first passthrough request exactly is a HIT
+--- request
+POST /v1/images/generations
+{"model":"dall-e-3","prompt":"ai-cache passthrough same body"}
+--- response_headers_like
+X-AI-Cache-Status: HIT
+--- response_body_like eval
+qr/baby sea otter/
+
+
+
+=== TEST 64: passthrough route with proxy-rewrite forcing the upstream method
to POST
+--- 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": "/v1/*",
+ "plugins": {
+ "proxy-rewrite": { "method": "POST" },
+ "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
+ }
+ }
+ }]]
+ )
+ if code >= 300 then
+ ngx.status = code
+ end
+ ngx.say(body)
+ }
+ }
+--- extra_yaml_config
+plugins:
+ - proxy-rewrite
+ - ai-proxy
+ - ai-cache
+--- response_body
+passed
+
+
+
+=== TEST 65: POST passthrough request is a MISS
+--- request
+POST /v1/images/generations
+{"model":"dall-e-3","prompt":"ai-cache passthrough rewritten method"}
+--- more_headers
+X-AI-Fixture: openai/images-generation.json
+--- extra_yaml_config
+plugins:
+ - proxy-rewrite
+ - ai-proxy
+ - ai-cache
+--- response_headers
+X-AI-Cache-Status: MISS
+--- response_body_like eval
+qr/baby sea otter/
+--- wait: 0.3
+
+
+
+=== TEST 66: PUT with the same body is rewritten to POST upstream, so it is a
HIT on the POST entry
+--- request
+PUT /v1/images/generations
+{"model":"dall-e-3","prompt":"ai-cache passthrough rewritten method"}
+--- extra_yaml_config
+plugins:
+ - proxy-rewrite
+ - ai-proxy
+ - ai-cache
+--- response_headers_like
+X-AI-Cache-Status: HIT
+--- response_body_like eval
+qr/baby sea otter/