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

Reply via email to