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 b83f32374 fix(ai-cache): preserve non-text content in exact key and L2
bypass (#13654)
b83f32374 is described below
commit b83f32374177250ce9a8922fc3103031710b18c2
Author: Shreemaan Abhishek <[email protected]>
AuthorDate: Fri Jul 3 18:01:26 2026 +0800
fix(ai-cache): preserve non-text content in exact key and L2 bypass (#13654)
---
apisix/plugins/ai-cache/key.lua | 4 ++
apisix/plugins/ai-cache/semantic.lua | 71 ++++++++++++++++++++++++++----------
t/plugin/ai-cache-semantic.t | 44 ++++++++++++++++++++++
3 files changed, 100 insertions(+), 19 deletions(-)
diff --git a/apisix/plugins/ai-cache/key.lua b/apisix/plugins/ai-cache/key.lua
index bd09cc486..f28cd63bb 100644
--- a/apisix/plugins/ai-cache/key.lua
+++ b/apisix/plugins/ai-cache/key.lua
@@ -103,6 +103,10 @@ end
-- 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))
+ -- client_messages() (get_messages) flattens structured content to plain
text,
+ -- so an exact fingerprint of it alone would let a text+image prompt
collide
+ -- with a text-only one. Fold the raw messages in to keep them distinct.
+ repr.raw_messages = body.messages
return hex_digest(core.json.canonical_encode(repr))
end
diff --git a/apisix/plugins/ai-cache/semantic.lua
b/apisix/plugins/ai-cache/semantic.lua
index 4c603a9dc..dfaf4d0d7 100644
--- a/apisix/plugins/ai-cache/semantic.lua
+++ b/apisix/plugins/ai-cache/semantic.lua
@@ -23,6 +23,7 @@ local vs =
require("apisix.plugins.ai-cache.vector-search.redis")
local ipairs = ipairs
local type = type
+local next = next
local concat = table.concat
local tostring = tostring
@@ -40,13 +41,13 @@ 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.
+-- the partition context. That is only sound when get_messages() is a faithful
+-- view of the prompt's TEXT. Today only openai-chat qualifies; other protocols
+-- reshape or drop text, so two distinct prompts could canonicalise to the same
+-- messages and collide on one cache cell. get_messages() also flattens away
all
+-- non-text content (images, tool calls), so non-text prompts are bypassed
+-- separately via body_has_nontext() below. L2 therefore engages only for these
+-- protocols (exact L1 still applies to the rest).
local SEMANTIC_PROTOCOLS = {
["openai-chat"] = true,
}
@@ -127,14 +128,46 @@ function _M.context_messages(messages, match)
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
+-- A content part that is present but not plain text (image, audio, tool
result).
+local function block_is_nontext(block)
+ return type(block) == "table" and block.type ~= nil and block.type ~=
"text"
+end
+
+
+local function is_nonempty_table(v)
+ return type(v) == "table" and next(v) ~= nil
+end
+
+
+-- True when the RAW body carries prompt state get_messages() drops: a non-text
+-- content block (image, audio, ...) or a tool/function call. That state is in
+-- neither the vector nor the L2 partition, so a same-text prompt that differs
+-- only there would otherwise collide on one L2 cell.
+-- Tolerant of malformed input: non-table message items and content shaped as a
+-- single block object (not an array) are handled, never indexed blindly.
+function _M.body_has_nontext(body)
+ local messages = type(body) == "table" and body.messages
+ if type(messages) ~= "table" then
+ return false
+ end
+ for _, msg in ipairs(messages) do
+ if type(msg) == "table" then
+ -- tool/function calls are response-determining prompt state
+ if is_nonempty_table(msg.tool_calls)
+ or is_nonempty_table(msg.function_call) then
+ return true
+ end
+ local content = msg.content
+ if type(content) == "table" then
+ -- content may be an array of blocks or a single block object
+ if block_is_nontext(content) then
return true
end
+ for _, block in ipairs(content) do
+ if block_is_nontext(block) then
+ return true
+ end
+ end
end
end
end
@@ -216,14 +249,14 @@ function _M.embed_query(conf, ctx, body)
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
+ -- Bypass L2 for prompts carrying non-text content (images, etc.): it
lives in
+ -- neither the vector nor the partition, so a same-text different-media
prompt
+ -- would otherwise collide on one L2 cell.
+ if _M.body_has_nontext(body) then
return nil
end
+ local sem = conf.semantic
+ local messages = key_mod.messages(ctx, body)
local text = _M.extract_embed_text(messages, sem.match)
if text == "" then
return nil
diff --git a/t/plugin/ai-cache-semantic.t b/t/plugin/ai-cache-semantic.t
index e7b22a7c6..4de4e50d6 100644
--- a/t/plugin/ai-cache-semantic.t
+++ b/t/plugin/ai-cache-semantic.t
@@ -1525,3 +1525,47 @@ X-AI-Cache-Status: MISS
}
--- 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