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 a2d34cd62 feat(ai-cache): add streaming support with format tagging
(#13644)
a2d34cd62 is described below
commit a2d34cd62a0c8c8981acfa13f3a6967832569ebd
Author: Mohammad Izzraff Janius
<[email protected]>
AuthorDate: Fri Jul 3 19:30:02 2026 +0800
feat(ai-cache): add streaming support with format tagging (#13644)
---
apisix/plugins/ai-cache.lua | 223 +++---
apisix/plugins/ai-cache/key.lua | 11 +-
apisix/plugins/ai-cache/semantic.lua | 23 +-
apisix/plugins/ai-cache/stream.lua | 102 +++
apisix/plugins/ai-cache/vector-search/redis.lua | 11 +-
apisix/plugins/ai-providers/base.lua | 11 +
docs/en/latest/plugins/ai-cache.md | 8 +
docs/zh/latest/plugins/ai-cache.md | 8 +
t/lib/ai_cache_mock.lua | 15 +
t/plugin/ai-cache-semantic.t | 35 +-
t/plugin/ai-cache-streaming.t | 909 ++++++++++++++++++++++++
t/plugin/ai-cache.t | 4 +-
12 files changed, 1220 insertions(+), 140 deletions(-)
diff --git a/apisix/plugins/ai-cache.lua b/apisix/plugins/ai-cache.lua
index 895ccc7c5..5a09f0d85 100644
--- a/apisix/plugins/ai-cache.lua
+++ b/apisix/plugins/ai-cache.lua
@@ -21,6 +21,7 @@ local key_mod = require("apisix.plugins.ai-cache.key")
local binding = require("apisix.plugins.ai-protocols.binding")
local redis_util = require("apisix.utils.redis")
local semantic = require("apisix.plugins.ai-cache.semantic")
+local stream = require("apisix.plugins.ai-cache.stream")
local ngx = ngx
local ngx_null = ngx.null
@@ -81,6 +82,53 @@ local function release(conf, red)
end
+-- Run fn(red) on a pooled connection: released on success, closed when fn
+-- returns an error or throws. Returns fn's result, or (nil, err) on any
failure.
+local function with_redis(conf, fn)
+ local red, err = redis_util.new(conf)
+ if not red then
+ return nil, err
+ end
+ local ok, res, ferr = pcall(fn, red)
+ if not ok or ferr then
+ red:close()
+ return nil, not ok and res or ferr
+ end
+ release(conf, red)
+ return res
+end
+
+
+-- fail-open: a cache-backend or embedding failure must never break the
+-- request; log it and treat the lookup as a MISS.
+local function fail_open(ctx, what, err)
+ core.log.warn("ai-cache: ", what, ", fail-open as MISS: ", err)
+ ctx.ai_cache_status = "MISS"
+end
+
+
+-- The L1 stored value; encoded only here so the shape has one home.
+local function encode_entry(body, created_at, format)
+ return core.json.encode({ body = body, created_at = created_at, format =
format })
+end
+
+
+-- Best-effort L2 -> L1 backfill under this request's L1 key, carrying
+-- created_at and format so either layer replays the hit identically.
+local function backfill_l1(conf, ctx, red, hit)
+ local envelope = encode_entry(hit.body, hit.created_at, hit.format)
+ if not envelope then
+ core.log.warn("ai-cache: L1 backfill skipped: json.encode returned
nil")
+ return
+ end
+ local ok, err = red:set(ctx.ai_cache_key, envelope,
+ "EX", (conf.exact and conf.exact.ttl) or
DEFAULT_TTL)
+ if not ok then
+ core.log.warn("ai-cache: L1 backfill SET failed: ", err)
+ end
+end
+
+
local function serve_hit(conf, ctx, cached, similarity)
local status = "HIT"
ctx.ai_cache_status = status
@@ -93,7 +141,9 @@ local function serve_hit(conf, ctx, cached, similarity)
str_format("%.4f", similarity))
end
end
- core.response.set_header("Content-Type", "application/json")
+ core.response.set_header("Content-Type",
+ cached.format == stream.FORMAT_SSE and "text/event-stream"
+ or "application/json")
return core.response.exit(200, cached.body)
end
@@ -111,10 +161,11 @@ function _M.access(conf, ctx)
return
end
- -- Streaming responses are not cached in PR-1 (SSE replay is a later
- -- increment). ai-proxy (higher priority) has already classified the
- -- request, so bypass before doing any work.
- if ctx.var.request_type == "ai_stream" then
+ -- A stream on a non-SSE wire framing (bedrock's aws-eventstream) can never
+ -- be captured or replayed, so the lookup would be a guaranteed-miss redis
+ -- GET on every request: bypass before doing any work.
+ if ctx.var.request_type == "ai_stream"
+ and not stream.provider_capturable(ctx.picked_ai_instance) then
ctx.ai_cache_status = "BYPASS"
return
end
@@ -137,78 +188,67 @@ function _M.access(conf, ctx)
ctx.ai_cache_fingerprint = key_mod.fingerprint(ctx, body)
ctx.ai_cache_key = key_mod.build(conf, ctx, ctx.ai_cache_fingerprint)
- -- Remember which instance the fingerprint was computed for. ai-proxy-multi
- -- may fall back to a different instance in before_proxy; the log phase
uses
- -- this to avoid writing that fallback response under the original key.
+ -- which instance the fingerprint was computed for; log() checks it so a
+ -- fallback instance's response is never written under this key
ctx.ai_cache_picked_at_access = ctx.picked_ai_instance
- local red
- red, err = redis_util.new(conf)
- if not red then
- -- fail-open: never let a cache-backend outage break the request.
- core.log.warn("ai-cache: redis unavailable, fail-open as MISS: ", err)
- ctx.ai_cache_status = "MISS"
- return
- end
-
- local res
- res, err = red:get(ctx.ai_cache_key)
- if err then
- red:close()
- core.log.warn("ai-cache: redis get failed, fail-open as MISS: ", err)
- ctx.ai_cache_status = "MISS"
- return
- end
- if res ~= nil and res ~= ngx_null then
- local cached = core.json.decode(res)
- if cached and cached.body then
- release(conf, red)
- return serve_hit(conf, ctx, cached)
+ local cached
+ cached, err = with_redis(conf, function(red)
+ local res, gerr = red:get(ctx.ai_cache_key)
+ if gerr then
+ return nil, gerr
+ end
+ if res == nil or res == ngx_null then
+ return nil
+ end
+ local entry = core.json.decode(res)
+ if entry and entry.body then
+ return entry
end
core.log.warn("ai-cache: discarding malformed cache entry for ",
ctx.ai_cache_key)
+ return nil
+ end)
+ if err then
+ return fail_open(ctx, "L1 lookup failed", err)
+ end
+ if cached then
+ return serve_hit(conf, ctx, cached)
end
- -- L1 miss -> L2 semantic lookup. Release the L1 connection before
- -- embed_query()'s HTTP call so the pool isn't pinned across the embedding
- -- round-trip; re-acquire for the vector search. pcall keeps throws
fail-open.
+ -- L1 miss -> L2 semantic lookup, in its own connection scope so the pool
+ -- isn't pinned across embed_query()'s HTTP round-trip.
if has_layer(conf, "semantic") and conf.semantic then
- release(conf, red)
-
local ok, vec = pcall(semantic.embed_query, conf, ctx, body)
if not ok then
- core.log.warn("ai-cache: semantic embed error, fail-open as MISS:
", vec)
- vec = nil
+ fail_open(ctx, "semantic embed error", vec)
-- prevent log() from scheduling a write with partial/bad state
ctx.ai_cache_embedding = nil
+ return
end
if vec then
- local sred
- sred, err = redis_util.new(conf)
- if not sred then
- core.log.warn("ai-cache: redis unavailable for semantic
search, ",
- "fail-open as MISS: ", err)
- else
- local sok, hit = pcall(semantic.search, sred, conf, ctx, vec)
- if not sok then
- sred:close()
- core.log.warn("ai-cache: semantic search error, fail-open
as MISS: ", hit)
- ctx.ai_cache_embedding = nil
- else
- release(conf, sred)
- if hit then
- return serve_hit(conf, ctx,
- { body = hit.body, created_at = hit.created_at },
hit.similarity)
+ local hit
+ hit, err = with_redis(conf, function(red)
+ local h = semantic.search(red, conf, ctx, vec)
+ if h then
+ local bok, berr = pcall(backfill_l1, conf, ctx, red, h)
+ if not bok then
+ core.log.warn("ai-cache: L1 backfill error: ", berr)
end
end
+ return h
+ end)
+ if err then
+ fail_open(ctx, "semantic search failed", err)
+ ctx.ai_cache_embedding = nil
+ return
+ end
+ if hit then
+ return serve_hit(conf, ctx, hit, hit.similarity)
end
end
-
- ctx.ai_cache_status = "MISS"
- return
end
- release(conf, red)
ctx.ai_cache_status = "MISS"
end
@@ -222,7 +262,8 @@ end
function _M.body_filter(conf, ctx)
-- only a MISS gets written back; HIT exited in access, BYPASS opts out.
- if ctx.ai_cache_status ~= "MISS" or ctx.ai_cache_oversized then
+ if ctx.ai_cache_status ~= "MISS" or ctx.ai_cache_oversized
+ or not stream.capturable(ctx) then
return
end
local chunk = ngx.arg[1]
@@ -244,36 +285,30 @@ function _M.body_filter(conf, ctx)
end
--- The response-capturing phases (body_filter / log) run in contexts where
--- cosockets are disabled, so the Redis write is deferred to a 0-delay timer
--- (timers run in a light thread where cosockets are allowed).
--- l2 (optional) = { partition, embedding, dim, fingerprint, ttl } for L2
write.
-local function write_to_cache(premature, conf, cache_key, response_body, l2)
+-- body_filter/log cannot use cosockets, so the Redis write runs in a 0-delay
+-- timer. l2 (optional) = { partition, embedding, dim, fingerprint, ttl }.
+local function write_to_cache(premature, conf, cache_key, response_body, l2,
format)
if premature then
return
end
- local red, err = redis_util.new(conf)
- if not red then
- core.log.warn("ai-cache: redis unavailable on write: ", err)
- return
- end
- local envelope = core.json.encode({ body = response_body, created_at =
ngx.time() })
- local ttl = (conf.exact and conf.exact.ttl) or DEFAULT_TTL
- local ok
- ok, err = red:set(cache_key, envelope, "EX", ttl)
- if not ok then
- red:close()
- core.log.warn("ai-cache: redis set failed: ", err)
- return
- end
- if l2 then
- l2.created_at = ngx.time()
- local wok, werr = pcall(semantic.write, red, conf, l2, response_body)
- if not wok then
- core.log.warn("ai-cache: semantic write error: ", werr)
+ local now = ngx.time()
+ local envelope = encode_entry(response_body, now, format)
+ local _, err = with_redis(conf, function(red)
+ local ok, serr = red:set(cache_key, envelope, "EX",
+ (conf.exact and conf.exact.ttl) or
DEFAULT_TTL)
+ if not ok then
+ return nil, serr
+ end
+ if l2 then
+ l2.created_at = now
+ l2.format = format
+ semantic.write(red, conf, l2, response_body)
end
+ return true
+ end)
+ if err then
+ core.log.warn("ai-cache: cache write failed: ", err)
end
- release(conf, red)
end
@@ -281,27 +316,32 @@ function _M.log(conf, ctx)
if ctx.ai_cache_status ~= "MISS" or not ctx.ai_cache_fingerprint then
return
end
- -- ai-proxy-multi may reassign the picked instance on fallback/retry during
- -- before_proxy. The frozen fingerprint identifies the ORIGINAL instance,
so a
- -- response actually produced by a different (fallback) instance must not
be
- -- written under it -- that would replay the wrong instance's response on a
- -- later hit.
+ -- the fingerprint identifies the instance picked at access time; a
+ -- fallback/retry response from another instance must not be cached under
it
if ctx.picked_ai_instance ~= ctx.ai_cache_picked_at_access then
return
end
if ngx.status ~= 200 then
return
end
+ if ctx.ai_stream_aborted then
+ return
+ end
local buf = ctx.ai_cache_buf
if not buf or buf.bytes == 0 then
return
end
local response_body = concat(buf, "", 1, buf.n)
+ local format = stream.capture_format(ctx, response_body)
+ if not format then
+ return
+ end
+
local cache_key = key_mod.build(conf, ctx, ctx.ai_cache_fingerprint)
- -- Build the L2 doc from ctx fields stashed by semantic.embed_query(); the
- -- embedding is only set on a successful embed, so a nil check guards the
write.
+ -- L2 doc from ctx fields stashed by semantic.embed_query(); the embedding
+ -- is only set on a successful embed.
local l2
if has_layer(conf, "semantic") and ctx.ai_cache_embedding then
l2 = {
@@ -313,7 +353,8 @@ function _M.log(conf, ctx)
}
end
- local ok, err = ngx.timer.at(0, write_to_cache, conf, cache_key,
response_body, l2)
+ local ok, err = ngx.timer.at(0, write_to_cache, conf, cache_key,
+ response_body, l2, format)
if not ok then
core.log.warn("ai-cache: failed to schedule cache write: ", err)
end
diff --git a/apisix/plugins/ai-cache/key.lua b/apisix/plugins/ai-cache/key.lua
index f28cd63bb..9c6542d28 100644
--- a/apisix/plugins/ai-cache/key.lua
+++ b/apisix/plugins/ai-cache/key.lua
@@ -66,6 +66,8 @@ local function build_repr(ctx, body, messages)
params[k] = v
end
end
+ local proto = ctx.ai_client_protocol and
protocols.get(ctx.ai_client_protocol)
+ params.stream = (proto and proto.is_streaming(body)) == true
return {
client = {
@@ -111,15 +113,6 @@ function _M.fingerprint(ctx, body)
end
--- Returns the SHA-256 hex digest of the effective context with message text
--- removed. Queries that differ only in phrasing (same model/params/instance)
--- share one fingerprint, enabling semantic deduplication without storing the
--- raw prompt.
-function _M.context_fingerprint(ctx, body)
- return hex_digest(core.json.canonical_encode(build_repr(ctx, body, nil)))
-end
-
-
-- Percent-encode "%", ":" and "=" (in that order) in scope values so a
request-controlled
-- include_vars value can't shift "name=value:" boundaries to forge another
scope.
local function esc(v)
diff --git a/apisix/plugins/ai-cache/semantic.lua
b/apisix/plugins/ai-cache/semantic.lua
index dfaf4d0d7..6cb580283 100644
--- a/apisix/plugins/ai-cache/semantic.lua
+++ b/apisix/plugins/ai-cache/semantic.lua
@@ -282,7 +282,8 @@ end
-- Phase 2 of the L2 lookup: vector search over a caller-owned connection
-- acquired AFTER embed_query() (so the pool isn't pinned across embedding).
--- Returns a hit {body, created_at, similarity} on a >=threshold match, else
nil.
+-- Returns a hit {body, created_at, format, similarity} on a >=threshold match,
+-- else nil. The L1 backfill of a hit is the caller's job (ai-cache.lua owns
L1).
function _M.search(red, conf, ctx, vec)
local sem = conf.semantic
local target = redis_target(conf)
@@ -309,26 +310,13 @@ function _M.search(red, conf, ctx, vec)
return nil
end
- -- L2 -> L1 backfill, carrying the L2 entry's original created_at so Age is
- -- consistent whether the next hit is served from L1 or L2. A real
semantic
- -- hit must be served regardless — only the backfill SET is skipped on
error.
- local envelope = core.json.encode({ body = hit.response, created_at =
hit.created_at })
- if not envelope then
- core.log.warn("ai-cache: L1 backfill skipped: json.encode returned
nil")
- else
- local exact_ttl = (conf.exact and conf.exact.ttl) or 3600
- local bok, berr = red:set(ctx.ai_cache_key, envelope, "EX", exact_ttl)
- if not bok then
- core.log.warn("ai-cache: L1 backfill SET failed: ", berr)
- end
- end
-
- return { body = hit.response, created_at = hit.created_at, similarity =
similarity }
+ return { body = hit.response, created_at = hit.created_at,
+ format = hit.format, similarity = similarity }
end
-- Called from the write-back timer (after the L1 SET) with a still-open `red`.
--- l2 = { partition, embedding, dim, fingerprint, ttl, created_at }
+-- l2 = { partition, embedding, dim, fingerprint, ttl, created_at, format }
function _M.write(red, conf, l2, response_body)
if not l2 or not l2.embedding then
return
@@ -347,6 +335,7 @@ function _M.write(red, conf, l2, response_body)
embedding = vs.pack_float32(l2.embedding),
response = response_body,
created_at = l2.created_at,
+ format = l2.format,
}, l2.ttl)
if not ok then
core.log.warn("ai-cache: L2 upsert failed: ", err)
diff --git a/apisix/plugins/ai-cache/stream.lua
b/apisix/plugins/ai-cache/stream.lua
new file mode 100644
index 000000000..a91fdcb3c
--- /dev/null
+++ b/apisix/plugins/ai-cache/stream.lua
@@ -0,0 +1,102 @@
+--
+-- 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.
+--
+--
+-- Streaming (SSE) capture + replay policy for ai-cache: body_filter buffers
+-- the raw client-wire frames, log() stores them once complete, serve_hit
+-- replays them as one text/event-stream body.
+--
+local sse = require("apisix.plugins.ai-transport.sse")
+local protocols = require("apisix.plugins.ai-protocols")
+
+local pcall = pcall
+local require = require
+local type = type
+
+local _M = {}
+
+-- Replay format tag stored on every cache entry (L1 envelope and L2 doc).
+_M.FORMAT_JSON = "json" -- single-shot application/json body
+_M.FORMAT_SSE = "sse" -- raw text/event-stream frames incl. terminal event
+
+
+-- Replayable: single-shot (no framing) or SSE. Binary framings
+-- (aws-eventstream) are not.
+function _M.capturable(ctx)
+ local framing = ctx.ai_stream_framing
+ return not framing or framing == "sse"
+end
+
+
+-- Access-time analog of capturable(): predicts from the picked provider
+-- whether a stream's framing is replayable, so access can skip the lookup.
+function _M.provider_capturable(instance)
+ local ok, provider = pcall(require,
+ "apisix.plugins.ai-providers." ..
instance.provider)
+ if not ok or type(provider) ~= "table" then
+ return true
+ end
+ local framing = provider.streaming_framing
+ return not framing or framing == "sse"
+end
+
+
+-- True when the buffer ends, at a frame boundary, with the client protocol's
+-- terminal event. Deliberately NOT ctx.var.llm_request_done, which is also set
+-- on aborts. The boundary check guards truncation: sse.decode treats a
trailing
+-- partial frame as a complete event, so a cut-off buffer could otherwise pass.
+function _M.stream_completed(ctx, body)
+ if not body or body == "" then
+ return false
+ end
+ local proto = ctx.ai_client_protocol and
protocols.get(ctx.ai_client_protocol)
+ if not (proto and proto.is_done_event) then
+ return false
+ end
+ local tail = body:sub(-256)
+ if not (tail:find("\n\n%s*$") or tail:find("\r\n\r\n%s*$")) then
+ return false
+ end
+ local events = sse.decode(body)
+ local last = events[#events]
+ return last ~= nil and proto.is_done_event(last) == true
+end
+
+
+local function looks_like_sse(body)
+ return body:find("^%s*data:") or body:find("^%s*event:")
+ or body:find("^%s*:") or body:find("^%s*id:") or
body:find("^%s*retry:")
+end
+
+
+-- Format tag to store for a MISS capture, or nil when it must not be cached
+-- (incomplete stream, SSE bytes without framing, non-SSE framing).
+function _M.capture_format(ctx, body)
+ local framing = ctx.ai_stream_framing
+ if not framing then
+ if looks_like_sse(body) then
+ return nil
+ end
+ return _M.FORMAT_JSON
+ end
+ if framing ~= "sse" or not _M.stream_completed(ctx, body) then
+ return nil
+ end
+ return _M.FORMAT_SSE
+end
+
+
+return _M
diff --git a/apisix/plugins/ai-cache/vector-search/redis.lua
b/apisix/plugins/ai-cache/vector-search/redis.lua
index 9b2adeaf7..b50cba17b 100644
--- a/apisix/plugins/ai-cache/vector-search/redis.lua
+++ b/apisix/plugins/ai-cache/vector-search/redis.lua
@@ -74,7 +74,8 @@ function _M.upsert(red, doc_key, fields, ttl)
"partition", fields.partition,
"embedding", fields.embedding,
"response", fields.response,
- "created_at", fields.created_at)
+ "created_at", fields.created_at,
+ "format", fields.format)
red:expire(doc_key, ttl)
local res, err = red:commit_pipeline()
if not res then
@@ -89,14 +90,14 @@ function _M.upsert(red, doc_key, fields, ttl)
end
--- Returns the nearest hit { distance, response, created_at } or nil (no err)
on
--- an empty result set.
+-- Returns the nearest hit { distance, response, created_at, format } or nil
(no
+-- err) on an empty result set.
function _M.knn_search(red, target, index, partition, vec, top_k)
local query = "(@partition:{" .. partition .. "})=>[KNN " .. top_k ..
" @embedding $vec AS __score]"
local res, err = red[ "FT.SEARCH" ](red, index, query,
"PARAMS", 2, "vec", _M.pack_float32(vec),
- "RETURN", 3, "__score", "response", "created_at",
+ "RETURN", 4, "__score", "response", "created_at", "format",
"SORTBY", "__score",
"DIALECT", 2)
if not res then
@@ -121,6 +122,8 @@ function _M.knn_search(red, target, index, partition, vec,
top_k)
hit.response = v
elseif k == "created_at" then
hit.created_at = tonumber(v)
+ elseif k == "format" then
+ hit.format = v
end
end
if not hit.response or not hit.distance then
diff --git a/apisix/plugins/ai-providers/base.lua
b/apisix/plugins/ai-providers/base.lua
index 50b56ca2a..a992defba 100644
--- a/apisix/plugins/ai-providers/base.lua
+++ b/apisix/plugins/ai-providers/base.lua
@@ -456,6 +456,14 @@ function _M.parse_streaming_response(self, ctx, res,
target_proto, converter, co
if not framing then
return 500, "unknown streaming framing: " ..
tostring(self.streaming_framing)
end
+ -- expose the wire framing so response-observing plugins (e.g. ai-cache)
+ -- can classify the stream without re-sniffing the content-type
+ ctx.ai_stream_framing = self.streaming_framing or "sse"
+ -- clear a previous attempt's abort flag: re-entry only happens when that
+ -- attempt emitted no output (with headers sent, the retry dies earlier in
+ -- core.response.set_header), so the flag is stale, not protective
+ ctx.ai_stream_aborted = nil
+ ngx.status = res.status
local body_reader = res.body_reader
local contents = {}
local sse_state = { is_first = true }
@@ -524,6 +532,7 @@ function _M.parse_streaming_response(self, ctx, res,
target_proto, converter, co
local function abort_on_disconnect(flush_err)
core.log.info("client disconnected during AI streaming, ",
"aborting upstream read: ", flush_err)
+ ctx.ai_stream_aborted = "client_disconnect"
if flush_thread then
ngx.thread.kill(flush_thread)
flush_thread = nil
@@ -549,6 +558,7 @@ function _M.parse_streaming_response(self, ctx, res,
target_proto, converter, co
local chunk, err = body_reader()
if err then
+ ctx.ai_stream_aborted = "read_error"
ctx.var.apisix_upstream_response_time = math.floor(
(ngx_now() - ctx.llm_request_start_time) * 1000)
core.log.warn("failed to read response chunk: ", err)
@@ -724,6 +734,7 @@ function _M.parse_streaming_response(self, ctx, res,
target_proto, converter, co
limit_hit = "max_response_bytes"
end
if limit_hit then
+ ctx.ai_stream_aborted = limit_hit
if flush_thread then
ngx.thread.kill(flush_thread)
flush_thread = nil
diff --git a/docs/en/latest/plugins/ai-cache.md
b/docs/en/latest/plugins/ai-cache.md
index d16a9a57f..907b8d177 100644
--- a/docs/en/latest/plugins/ai-cache.md
+++ b/docs/en/latest/plugins/ai-cache.md
@@ -47,6 +47,14 @@ This Plugin supports two cache layers:
The `ai-cache` Plugin must be used with the [`ai-proxy`](./ai-proxy.md) or
[`ai-proxy-multi`](./ai-proxy-multi.md) Plugin.
+### Streaming
+
+Streaming (SSE) responses are cached and replayed. A streamed response is
written to the cache only after it completes — that is, the terminal event for
the client protocol is received (`data: [DONE]` for OpenAI, `message_stop` for
Anthropic, `response.completed` for OpenAI Responses). A stream that is
interrupted (client disconnect, or the `ai-proxy` `max_stream_duration_ms` /
`max_response_bytes` limits) is never cached, so partial responses are never
served. On a hit, the stored respo [...]
+
+Streaming and non-streaming requests for the same prompt are cached as
**separate** entries at both layers, so a streaming client always receives a
stream and a non-streaming client always receives a single JSON response. This
applies whether streaming is requested by the client (`"stream": true`) or
forced by the route via `options.stream`.
+
+Limitations: binary streaming formats without an SSE terminal event (for
example Bedrock ConverseStream) are not cached. Replay is immediate (the full
stored response is sent at once) rather than re-timed token-by-token.
+
:::note
By default the cache is isolated per route, so two routes never serve each
other's entries even when they see the same protocol, model and messages. Set
`cache_key.share_across_routes` to `true` to share one cache space across
routes.
diff --git a/docs/zh/latest/plugins/ai-cache.md
b/docs/zh/latest/plugins/ai-cache.md
index 16f3c2b7c..5c9e51234 100644
--- a/docs/zh/latest/plugins/ai-cache.md
+++ b/docs/zh/latest/plugins/ai-cache.md
@@ -47,6 +47,14 @@ import TabItem from '@theme/TabItem';
`ai-cache` 插件必须与 [`ai-proxy`](./ai-proxy.md) 或
[`ai-proxy-multi`](./ai-proxy-multi.md) 插件一起使用。
+### 流式响应
+
+插件支持缓存并回放流式(SSE)响应。流式响应仅在**完成后**才写入缓存,即接收到客户端协议对应的终止事件(OpenAI 为 `data:
[DONE]`,Anthropic 为 `message_stop`,OpenAI Responses 为
`response.completed`)。被中断的流(客户端断开连接,或触发 `ai-proxy` 的 `max_stream_duration_ms` /
`max_response_bytes` 限制)不会被缓存,因此不会回放不完整的响应。命中缓存时,存储的响应会作为单个 `text/event-stream`
响应体完整回放,并保留其终止事件。
+
+对于相同的提示词,流式请求与非流式请求会在两个缓存层中分别存储为**独立**的条目,因此流式客户端始终收到流式响应,非流式客户端始终收到单个 JSON
响应。无论流式是由客户端请求(`"stream": true`)还是由路由通过 `options.stream` 强制开启,均是如此。
+
+限制:不含 SSE 终止事件的二进制流式格式(例如 Bedrock
ConverseStream)不会被缓存;回放是即时的(一次性发送完整的存储响应),而非按 token 重新计时逐个发送。
+
:::note
默认情况下缓存按路由隔离,因此即使两个路由看到相同的协议、模型与消息,也不会相互返回对方的缓存条目。将
`cache_key.share_across_routes` 设为 `true` 可让多个路由共享同一个缓存空间。
diff --git a/t/lib/ai_cache_mock.lua b/t/lib/ai_cache_mock.lua
index 733014bfc..18de8bbce 100644
--- a/t/lib/ai_cache_mock.lua
+++ b/t/lib/ai_cache_mock.lua
@@ -101,6 +101,21 @@ function _M.embeddings_azure()
end
+-- First hit: 200 + SSE headers then stall (read timeout, no body); later hits
serve the fixture.
+local flaky_hits = 0
+function _M.chat_flaky_once()
+ flaky_hits = flaky_hits + 1
+ if flaky_hits == 1 then
+ ngx.header["Content-Type"] = "text/event-stream"
+ ngx.send_headers()
+ ngx.flush(true)
+ ngx.sleep(2)
+ return
+ end
+ fixture_loader.dispatch()
+end
+
+
-- always 5xx: drives the embedding-provider fail-open path.
function _M.broken()
ngx.status = 500
diff --git a/t/plugin/ai-cache-semantic.t b/t/plugin/ai-cache-semantic.t
index 4de4e50d6..db2a15d93 100644
--- a/t/plugin/ai-cache-semantic.t
+++ b/t/plugin/ai-cache-semantic.t
@@ -334,30 +334,31 @@ qr/property "message_countback" validation failed:
expected 0 to be at least 1/
-=== TEST 13: context_fingerprint ignores message TEXT but reacts to
model/params (key.lua unit)
+=== 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 context fingerprint deduplicates by the EFFECTIVE request
context
+ -- the partition deduplicates by the EFFECTIVE request context
-- (provider/model/params/instance) with the message wording
removed, so a
- -- paraphrase collapses to one fingerprint while a parameter
change does not.
+ -- 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 = {} }
+ var = { route_id = "1" } }
end
- local a = key.context_fingerprint(ctx(), { model = "gpt-4o",
temperature = 0.2,
- messages = {{ role = "user", content = "how do I return an
item?" }} })
- local b = key.context_fingerprint(ctx(), { model = "gpt-4o",
temperature = 0.2,
- messages = {{ role = "user", content = "what is the return
policy?" }} })
- local c = key.context_fingerprint(ctx(), { model = "gpt-4o",
temperature = 0.9,
- messages = {{ role = "user", content = "how do I return an
item?" }} })
-
- assert(a == b, "differently-worded prompts must share one context
fingerprint")
- assert(a ~= c, "a parameter change (temperature) must flip the
context fingerprint")
+ 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")
}
}
@@ -619,10 +620,10 @@ passed
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 }, 600))
+ 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 }, 600))
+ 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
@@ -672,10 +673,10 @@ clean-miss
-- 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 }, 600))
+ 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 }, 600))
+ 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")
diff --git a/t/plugin/ai-cache-streaming.t b/t/plugin/ai-cache-streaming.t
new file mode 100644
index 000000000..6a18e9b02
--- /dev/null
+++ b/t/plugin/ai-cache-streaming.t
@@ -0,0 +1,909 @@
+#
+# 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.
+#
+
+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 6724;
+ default_type 'text/event-stream';
+
+ location /v1/embeddings {
+ content_by_lua_block { require("lib.ai_cache_mock").embeddings() }
+ }
+
+ location /v1/chat/completions-truncated {
+ content_by_lua_block {
+ ngx.header["Content-Type"] = "text/event-stream"
+ ngx.print('data:
{"id":"1","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"Hel"},"finish_reason":null}]}\\n\\n')
+ ngx.flush(true)
+ }
+ }
+
+ location /v1/chat/completions-runaway {
+ content_by_lua_block {
+ ngx.header["Content-Type"] = "text/event-stream"
+ for i = 1, 40 do
+ ngx.print('data:
{"id":"1","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"tokentokentokentokentoken"},"finish_reason":null}]}\\n\\n')
+ ngx.flush(true)
+ end
+ ngx.print('data: [DONE]\\n\\n')
+ ngx.flush(true)
+ }
+ }
+
+ # An SSE stream carried under a non-success status (HTTP 400) that
still
+ # ends with a valid [DONE] sentinel: ai-proxy's 429/5xx gate does not
catch
+ # 400, so without status propagation this would stream as 200 and get
+ # cached as a successful HIT.
+ location /v1/chat/completions-error-sse {
+ content_by_lua_block {
+ ngx.status = 400
+ ngx.header["Content-Type"] = "text/event-stream"
+ ngx.print('data: {"error":{"message":"bad request"}}\\n\\n')
+ ngx.print('data: [DONE]\\n\\n')
+ ngx.flush(true)
+ }
+ }
+
+ location /v1/chat/completions-flaky-once {
+ content_by_lua_block {
require("lib.ai_cache_mock").chat_flaky_once() }
+ }
+ }
+_EOC_
+ $block->set_value("http_config", $http_config);
+});
+
+run_tests();
+
+__DATA__
+
+=== TEST 1: folding stream into the fingerprint isolates stream from non-stream
+--- config
+ location /t {
+ content_by_lua_block {
+ local key = require("apisix.plugins.ai-cache.key")
+ local ctx = {
+ picked_ai_instance = { provider = "openai", options = { model
= "gpt-4" } },
+ ai_client_protocol = "openai-chat",
+ var = { route_id = "1" },
+ }
+ local base = { messages = {{ role = "user", content = "hi" }},
model = "gpt-4" }
+ local stream = { messages = {{ role = "user", content = "hi" }},
model = "gpt-4", stream = true }
+ local fp_a = key.fingerprint(ctx, base)
+ local fp_b = key.fingerprint(ctx, stream)
+ local conf = { cache_key = {} }
+ local pt_a = key.partition(conf, ctx, base, nil)
+ local pt_b = key.partition(conf, ctx, stream, nil)
+ ngx.say((fp_a ~= fp_b) and (pt_a ~= pt_b) and "ISOLATED" or
"COLLISION")
+ }
+ }
+--- response_body
+ISOLATED
+
+
+
+=== TEST 2: stream_completed is protocol-aware (openai [DONE], anthropic
message_stop) and immune to a [DONE] substring
+--- config
+ location /t {
+ content_by_lua_block {
+ local stream = require("apisix.plugins.ai-cache.stream")
+ local octx = { ai_client_protocol = "openai-chat", var = {} }
+ local actx = { ai_client_protocol = "anthropic-messages", var = {}
}
+ local complete = 'data:
{"choices":[{"delta":{"content":"Hi"}}]}\n\n' .. 'data: [DONE]\n\n'
+ local truncated = 'data:
{"choices":[{"delta":{"content":"Hi"}}]}\n\n'
+ -- a "[DONE]" substring inside content must NOT count as completion
+ local fake_done = 'data:
{"choices":[{"delta":{"content":"[DONE]"}}]}\n\n'
+ -- anthropic's terminal sentinel is the message_stop event, not
[DONE]
+ local anthropic_done = 'event: message_stop\ndata: {}\n\n'
+ local anthropic_truncated = 'event: message_start\ndata: {}\n\n'
+ -- a terminal frame cut off mid-data (no closing blank line) must
NOT
+ -- count as complete, even though its event TYPE parses as
message_stop
+ local anthropic_partial = 'event: message_start\ndata: {}\n\n'
+ .. 'event: message_stop\ndata: {"ty'
+ -- spec-legal comment/heartbeat frames AFTER the terminal event
must
+ -- not hide it (upstreams/proxies that ping after [DONE])
+ local keepalive_after_done = complete .. ': keepalive\n\n'
+ -- ...but a comment truncated mid-write is not a frame boundary
+ local keepalive_truncated = complete .. ': keepal'
+ ngx.say(table.concat({
+ tostring(stream.stream_completed(octx, complete)),
+ tostring(stream.stream_completed(octx, truncated)),
+ tostring(stream.stream_completed(octx, fake_done)),
+ tostring(stream.stream_completed(actx, anthropic_done)),
+ tostring(stream.stream_completed(actx, anthropic_truncated)),
+ tostring(stream.stream_completed(actx, anthropic_partial)),
+ tostring(stream.stream_completed(octx, keepalive_after_done)),
+ tostring(stream.stream_completed(octx, keepalive_truncated)),
+ }, ","))
+ }
+ }
+--- response_body
+true,false,false,true,false,false,true,false
+
+
+
+=== TEST 3: capture_format tags a capture by wire framing and completeness
+--- config
+ location /t {
+ content_by_lua_block {
+ local stream = require("apisix.plugins.ai-cache.stream")
+ -- framing is stamped by ai-providers parse_streaming_response;
+ -- absent means the response was a single-shot (non-streaming) body
+ local plain = { var = {} }
+ local sse_ctx = { ai_stream_framing = "sse",
+ ai_client_protocol = "openai-chat", var = {} }
+ local bin_ctx = { ai_stream_framing = "aws-eventstream",
+ ai_client_protocol = "bedrock-converse", var =
{} }
+ local done = 'data: {"choices":[]}\n\ndata: [DONE]\n\n'
+ ngx.say(table.concat({
+ stream.capture_format(plain, '{"id":"1"}'), -- json
+ stream.capture_format(sse_ctx, done), -- sse
+ tostring(stream.capture_format(sse_ctx, 'data: {}\n\n')), --
nil: incomplete
+ tostring(stream.capture_format(bin_ctx, done)), -- nil:
binary framing
+ tostring(stream.capture_format(plain, done)), -- nil:
mislabeled sse
+ tostring(stream.capture_format(plain, ': ping\ndata:
{}\n\n')), -- nil: comment first line
+ tostring(stream.capturable(plain)), -- true
+ tostring(stream.capturable(sse_ctx)), -- true
+ tostring(stream.capturable(bin_ctx)), -- false
+ -- access-time prediction from the picked provider
+ tostring(stream.provider_capturable({provider = "openai"})),
-- true
+ tostring(stream.provider_capturable({provider = "bedrock"})),
-- false
+ }, ","))
+ }
+ }
+--- response_body
+json,sse,nil,nil,nil,nil,true,true,false,true,false
+
+
+
+=== TEST 4: set a streaming route (ai-proxy stream + ai-cache), pointed at the
:1980 SSE fixture
+--- 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": "/stream",
+ "plugins": {
+ "ai-proxy": {
+ "provider": "openai",
+ "auth": { "header": { "Authorization": "Bearer test" }
},
+ "options": { "model": "gpt-4", "stream": true },
+ "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: a cold streaming request is a MISS and is streamed from upstream
+--- request
+POST /stream
+{"messages":[{"role":"user","content":"hi"}],"model":"gpt-4","stream":true}
+--- more_headers
+X-AI-Fixture: openai/chat-streaming.sse
+--- response_headers_like
+X-AI-Cache-Status: MISS
+Content-Type: text/event-stream
+--- response_body_like
+data: \[DONE\]
+--- wait: 0.5
+
+
+
+=== TEST 6: an identical streaming request is a HIT, replayed as a valid
text/event-stream
+--- request
+POST /stream
+{"messages":[{"role":"user","content":"hi"}],"model":"gpt-4","stream":true}
+--- more_headers
+X-AI-Fixture: openai/chat-streaming.sse
+--- response_headers_like
+X-AI-Cache-Status: HIT
+X-AI-Cache-Age: \d+
+Content-Type: text/event-stream
+--- response_body_like
+data: \[DONE\]
+
+
+
+=== TEST 7: set a route whose upstream truncates the stream (no terminal
[DONE])
+--- 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": "/stream-trunc",
+ "plugins": {
+ "ai-proxy": {
+ "provider": "openai",
+ "auth": { "header": { "Authorization": "Bearer test" }
},
+ "options": { "model": "gpt-4", "stream": true },
+ "override": { "endpoint":
"http://127.0.0.1:6724/v1/chat/completions-truncated" }
+ },
+ "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 8: the truncated stream is a MISS and is NOT written back (no
terminal sentinel)
+--- request
+POST /stream-trunc
+{"messages":[{"role":"user","content":"hi"}],"model":"gpt-4","stream":true}
+--- response_headers_like
+X-AI-Cache-Status: MISS
+--- wait: 0.5
+
+
+
+=== TEST 9: an identical request is STILL a MISS -- the truncated stream was
never cached
+--- request
+POST /stream-trunc
+{"messages":[{"role":"user","content":"hi"}],"model":"gpt-4","stream":true}
+--- response_headers_like
+X-AI-Cache-Status: MISS
+
+
+
+=== TEST 10: set a route that FORCES streaming via options.stream (client body
has none)
+--- 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": "/forced-stream",
+ "plugins": {
+ "ai-proxy": {
+ "provider": "openai",
+ "auth": { "header": { "Authorization": "Bearer test" }
},
+ "options": { "model": "gpt-4", "stream": true },
+ "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 11: options.stream makes the response SSE though request_type is
ai_chat -- MISS, streamed
+--- request
+POST /forced-stream
+{"messages":[{"role":"user","content":"hi"}],"model":"gpt-4"}
+--- more_headers
+X-AI-Fixture: openai/chat-streaming.sse
+--- response_headers_like
+X-AI-Cache-Status: MISS
+Content-Type: text/event-stream
+--- wait: 0.5
+
+
+
+=== TEST 12: the forced-stream entry replays as text/event-stream (format
follows the response, not request_type)
+--- request
+POST /forced-stream
+{"messages":[{"role":"user","content":"hi"}],"model":"gpt-4"}
+--- more_headers
+X-AI-Fixture: openai/chat-streaming.sse
+--- response_headers_like
+X-AI-Cache-Status: HIT
+Content-Type: text/event-stream
+--- response_body_like
+data: \[DONE\]
+
+
+
+=== TEST 13: set a non-streaming and a streaming route that share one cache
space
+--- 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 = t('/apisix/admin/routes/1', ngx.HTTP_PUT, [[{
+ "uri": "/iso-json", "plugins": {
+ "ai-proxy": { "provider": "openai",
+ "auth": { "header": { "Authorization": "Bearer test" }
},
+ "options": { "model": "gpt-4" },
+ "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; ngx.say(code); return end
+ code = t('/apisix/admin/routes/2', ngx.HTTP_PUT, [[{
+ "uri": "/iso-sse", "plugins": {
+ "ai-proxy": { "provider": "openai",
+ "auth": { "header": { "Authorization": "Bearer test" }
},
+ "options": { "model": "gpt-4", "stream": true },
+ "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 } } } }]])
+ ngx.say(code < 300 and "passed" or code)
+ }
+ }
+--- response_body
+passed
+
+
+
+=== TEST 14: prime the non-streaming (JSON) entry
+--- request
+POST /iso-json
+{"messages":[{"role":"user","content":"hi"}],"model":"gpt-4"}
+--- more_headers
+X-AI-Fixture: openai/chat-basic.json
+--- response_headers_like
+X-AI-Cache-Status: MISS
+--- wait: 0.5
+
+
+
+=== TEST 15: the SAME prompt as a stream is a MISS (never serves the JSON
entry) and is SSE
+--- request
+POST /iso-sse
+{"messages":[{"role":"user","content":"hi"}],"model":"gpt-4","stream":true}
+--- more_headers
+X-AI-Fixture: openai/chat-streaming.sse
+--- response_headers_like
+X-AI-Cache-Status: MISS
+Content-Type: text/event-stream
+--- wait: 0.5
+
+
+
+=== TEST 16: reverse isolation -- flush and reuse the shared routes from TEST
13
+--- config
+ location /t {
+ content_by_lua_block {
+ require("lib.test_redis").flush_port("127.0.0.1", 6379)
+ ngx.say("flushed")
+ }
+ }
+--- response_body
+flushed
+
+
+
+=== TEST 17: prime the streaming (SSE) entry
+--- request
+POST /iso-sse
+{"messages":[{"role":"user","content":"hi"}],"model":"gpt-4","stream":true}
+--- more_headers
+X-AI-Fixture: openai/chat-streaming.sse
+--- response_headers_like
+X-AI-Cache-Status: MISS
+--- wait: 0.5
+
+
+
+=== TEST 18: the SAME prompt non-streamed is a MISS (never serves the SSE
entry) and is JSON
+--- request
+POST /iso-json
+{"messages":[{"role":"user","content":"hi"}],"model":"gpt-4"}
+--- more_headers
+X-AI-Fixture: openai/chat-basic.json
+--- response_headers_like
+X-AI-Cache-Status: MISS
+Content-Type: application/json
+
+
+
+=== TEST 19: set a runaway streaming route with a low max_response_bytes
+--- 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": "/stream-runaway",
+ "plugins": {
+ "ai-proxy": {
+ "provider": "openai",
+ "auth": { "header": { "Authorization": "Bearer test" }
},
+ "options": { "model": "gpt-4", "stream": true },
+ "max_response_bytes": 512,
+ "override": { "endpoint":
"http://127.0.0.1:6724/v1/chat/completions-runaway" }
+ },
+ "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 20: max_response_bytes aborts the stream (llm_request_done=true)
before [DONE]
+--- request
+POST /stream-runaway
+{"messages":[{"role":"user","content":"hi"}],"model":"gpt-4","stream":true}
+--- error_log
+aborting AI stream: max_response_bytes exceeded
+--- wait: 0.5
+
+
+
+=== TEST 21: the aborted stream was NOT cached -- an identical request is
STILL a MISS
+--- request
+POST /stream-runaway
+{"messages":[{"role":"user","content":"hi"}],"model":"gpt-4","stream":true}
+--- response_headers_like
+X-AI-Cache-Status: MISS
+
+
+
+=== TEST 22: set a semantic (exact+semantic) streaming route
+--- 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": "/sse-sem", "plugins": {
+ "ai-proxy": { "provider": "openai",
+ "auth": { "header": { "Authorization": "Bearer
test-key" } },
+ "options": { "model": "gpt-4o", "stream": true },
+ "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 23: a cold streaming prompt is a semantic MISS
+--- request
+POST /sse-sem
+{"model":"gpt-4o","messages":[{"role":"user","content":"What is the capital of
France?"}],"stream":true}
+--- more_headers
+X-AI-Fixture: openai/chat-streaming.sse
+--- response_headers_like
+X-AI-Cache-Status: MISS
+--- wait: 0.6
+
+
+
+=== TEST 24: a paraphrased streaming prompt is a semantic (L2) HIT, replayed
as SSE
+--- request
+POST /sse-sem
+{"model":"gpt-4o","messages":[{"role":"user","content":"What is the capital
city of France?"}],"stream":true}
+--- more_headers
+X-AI-Fixture: openai/chat-streaming.sse
+--- response_headers_like
+X-AI-Cache-Status: HIT
+X-AI-Cache-Similarity: 0\.92\d\d
+Content-Type: text/event-stream
+--- response_body_like
+data: \[DONE\]
+
+
+
+=== TEST 25: set a streaming and a non-streaming semantic route over one
shared index
+--- 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 function sem(uri, stream_opt)
+ return [[{
+ "uri": "]] .. uri .. [[", "plugins": {
+ "ai-proxy": { "provider": "openai",
+ "auth": { "header": { "Authorization": "Bearer
test-key" } },
+ "options": { "model": "gpt-4o"]] .. stream_opt ..
[[ },
+ "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 },
+ "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":
"ai-cache-iso" } } } } } }]]
+ end
+ local code = t('/apisix/admin/routes/1', ngx.HTTP_PUT,
sem("/sse-sem2", ', "stream": true'))
+ if code >= 300 then ngx.status = code; ngx.say(code); return end
+ code = t('/apisix/admin/routes/2', ngx.HTTP_PUT, sem("/json-sem2",
""))
+ ngx.say(code < 300 and "passed" or code)
+ }
+ }
+--- response_body
+passed
+
+
+
+=== TEST 26: prime a STREAM L2 doc
+--- request
+POST /sse-sem2
+{"model":"gpt-4o","messages":[{"role":"user","content":"What is the capital of
France?"}],"stream":true}
+--- more_headers
+X-AI-Fixture: openai/chat-streaming.sse
+--- response_headers_like
+X-AI-Cache-Status: MISS
+--- wait: 0.6
+
+
+
+=== TEST 27: a non-stream paraphrase MISSES (the stream doc is in a different
partition)
+--- request
+POST /json-sem2
+{"model":"gpt-4o","messages":[{"role":"user","content":"What is the capital
city of France?"}]}
+--- more_headers
+X-AI-Fixture: openai/chat-basic.json
+--- response_headers_like
+X-AI-Cache-Status: MISS
+--- wait: 0.6
+
+
+
+=== TEST 28: a non-stream paraphrase of TEST 27 HITs L2 -- L2 works for
non-stream, stream doc stayed isolated
+--- request
+POST /json-sem2
+{"model":"gpt-4o","messages":[{"role":"user","content":"What is the capital of
France?"}]}
+--- more_headers
+X-AI-Fixture: openai/chat-basic.json
+--- response_headers_like
+X-AI-Cache-Status: HIT
+Content-Type: application/json
+
+
+
+=== TEST 29: buffered/one-write upstream + low max_response_bytes (limit trips
AFTER [DONE] is buffered)
+--- 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
+ -- The shared :1980 loader sends the whole .sse fixture (826 bytes,
+ -- ending with [DONE]) in ONE write, so the terminal sentinel is
+ -- already in the captured buffer when max_response_bytes (256)
trips.
+ -- stream_completed() alone would treat this as a cacheable
complete
+ -- stream; ctx.ai_stream_aborted must veto the write.
+ local code, body = t('/apisix/admin/routes/1', ngx.HTTP_PUT, [[{
+ "uri": "/stream-onewrite",
+ "plugins": {
+ "ai-proxy": {
+ "provider": "openai",
+ "auth": { "header": { "Authorization": "Bearer test" }
},
+ "options": { "model": "gpt-4", "stream": true },
+ "max_response_bytes": 256,
+ "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 30: the one-write stream carries [DONE] yet max_response_bytes still
aborts it
+--- request
+POST /stream-onewrite
+{"messages":[{"role":"user","content":"hi"}],"model":"gpt-4","stream":true}
+--- more_headers
+X-AI-Fixture: openai/chat-streaming.sse
+--- error_log
+aborting AI stream: max_response_bytes exceeded
+--- wait: 0.5
+
+
+
+=== TEST 31: the limit-aborted stream was NOT cached despite the buffered
[DONE] -- identical request STILL a MISS
+--- request
+POST /stream-onewrite
+{"messages":[{"role":"user","content":"hi"}],"model":"gpt-4","stream":true}
+--- more_headers
+X-AI-Fixture: openai/chat-streaming.sse
+--- response_headers_like
+X-AI-Cache-Status: MISS
+--- wait: 0.5
+
+
+
+=== TEST 32: stream folds by `== true` (mirrors is_streaming);
false/omitted/null/0 share ONE key, only true isolates
+--- config
+ location /t {
+ content_by_lua_block {
+ local key = require("apisix.plugins.ai-cache.key")
+ local null = require("cjson").null -- what cjson.decode gives
for JSON null
+ local ctx = {
+ picked_ai_instance = { provider = "openai", options = { model
= "gpt-4" } },
+ ai_client_protocol = "openai-chat",
+ var = { route_id = "1" },
+ }
+ local msgs = {{ role = "user", content = "hi" }}
+ local conf = { cache_key = {} }
+ local function fp(stream_val)
+ local body = { messages = msgs, model = "gpt-4" }
+ if stream_val ~= nil then body.stream = stream_val end
+ return key.fingerprint(ctx, body)
+ end
+ -- ai-proxy is_streaming does `body.stream == true`, so null
(truthy
+ -- userdata) and 0 (truthy in Lua) are NON-streaming and must not
split
+ -- off from omitted/false, nor collide with a real streaming
request.
+ local base = fp(nil)
+ local non_stream = (fp(false) == base) and (fp(null) == base) and
(fp(0) == base)
+ ngx.say((non_stream and fp(true) ~= base) and "CANONICAL" or
"FRAGMENTED")
+ }
+ }
+--- response_body
+CANONICAL
+
+
+
+=== TEST 33: set a streaming route whose upstream returns HTTP 400 SSE ending
in [DONE]
+--- 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": "/stream-error-sse",
+ "plugins": {
+ "ai-proxy": {
+ "provider": "openai",
+ "auth": { "header": { "Authorization": "Bearer test" }
},
+ "options": { "model": "gpt-4", "stream": true },
+ "override": { "endpoint":
"http://127.0.0.1:6724/v1/chat/completions-error-sse" }
+ },
+ "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 34: the 400 upstream status is propagated (not served as 200) and the
error stream is a MISS
+--- request
+POST /stream-error-sse
+{"messages":[{"role":"user","content":"hi"}],"model":"gpt-4","stream":true}
+--- error_code: 400
+--- response_headers_like
+X-AI-Cache-Status: MISS
+--- wait: 0.5
+
+
+
+=== TEST 35: the 400 error stream was NOT cached despite the [DONE] sentinel
-- identical request STILL a MISS (400)
+--- request
+POST /stream-error-sse
+{"messages":[{"role":"user","content":"hi"}],"model":"gpt-4","stream":true}
+--- error_code: 400
+--- response_headers_like
+X-AI-Cache-Status: MISS
+--- wait: 0.5
+
+
+
+=== TEST 36: set an ai-proxy-multi route on the flaky-once upstream (weight-0
spare keeps the re-pick on the SAME instance)
+--- 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": "/stream-retry",
+ "plugins": {
+ "ai-proxy-multi": {
+ "fallback_strategy": ["http_5xx"],
+ "timeout": 1000,
+ "ssl_verify": false,
+ "instances": [
+ {"name":"flaky","provider":"openai","weight":1,
+ "auth":{"header":{"Authorization":"Bearer test"}},
+ "options":{"model":"gpt-4","stream":true},
+
"override":{"endpoint":"http://127.0.0.1:6724/v1/chat/completions-flaky-once"}},
+ {"name":"spare","provider":"openai","weight":0,
+ "auth":{"header":{"Authorization":"Bearer test"}},
+ "options":{"model":"gpt-4","stream":true},
+
"override":{"endpoint":"http://127.0.0.1:6724/v1/chat/completions-flaky-once"}}
+ ]
+ },
+ "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 37: attempt 1 dies before the first byte, the retry re-picks the SAME
instance and streams to [DONE]
+--- extra_yaml_config
+plugins:
+ - ai-proxy-multi
+ - ai-cache
+--- request
+POST /stream-retry
+{"messages":[{"role":"user","content":"hi"}],"model":"gpt-4","stream":true}
+--- more_headers
+X-AI-Fixture: openai/chat-streaming.sse
+--- response_headers_like
+X-AI-Cache-Status: MISS
+Content-Type: text/event-stream
+--- response_body_like
+data: \[DONE\]
+--- error_log
+failed to read response chunk
+falling back to flaky
+--- timeout: 5
+--- wait: 0.5
+
+
+
+=== TEST 38: the retried stream WAS cached -- attempt 1's stale abort flag no
longer vetoes the write
+--- extra_yaml_config
+plugins:
+ - ai-proxy-multi
+ - ai-cache
+--- request
+POST /stream-retry
+{"messages":[{"role":"user","content":"hi"}],"model":"gpt-4","stream":true}
+--- more_headers
+X-AI-Fixture: openai/chat-streaming.sse
+--- response_headers_like
+X-AI-Cache-Status: HIT
+Content-Type: text/event-stream
+--- response_body_like
+data: \[DONE\]
+
+
+
+=== TEST 39: set a bedrock streaming route (aws-eventstream framing) with
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": "/bedrock-stream/converse",
+ "plugins": {
+ "ai-proxy": {
+ "provider": "bedrock",
+ "auth": {
+ "aws": {
+ "access_key_id": "AKIAIOSFODNN7EXAMPLE",
+ "secret_access_key":
"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
+ }
+ },
+ "provider_conf": { "region": "us-east-1" },
+ "options": { "model":
"anthropic.claude-3-5-sonnet-20241022-v2:0" },
+ "override": { "endpoint": "http://127.0.0.1:1980" },
+ "ssl_verify": false
+ },
+ "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 40: a binary-framed stream BYPASSes the lookup (could never be
written back) and flows through intact
+--- main_config
+ env AWS_EC2_METADATA_DISABLED=true;
+--- request
+POST /bedrock-stream/converse
+{"stream":true,"messages":[{"role":"user","content":[{"text":"Say hi"}]}]}
+--- error_code: 200
+--- response_headers
+X-AI-Cache-Status: BYPASS
+Content-Type: application/vnd.amazon.eventstream
+--- response_body eval
+qr/messageStart.*contentBlockDelta.*messageStop/s
+--- wait: 0.5
+
+
+
+=== TEST 41: the bypassed stream was never cached -- identical request STILL a
BYPASS
+--- main_config
+ env AWS_EC2_METADATA_DISABLED=true;
+--- request
+POST /bedrock-stream/converse
+{"stream":true,"messages":[{"role":"user","content":[{"text":"Say hi"}]}]}
+--- error_code: 200
+--- response_headers
+X-AI-Cache-Status: BYPASS
+Content-Type: application/vnd.amazon.eventstream
+
+
+
+=== TEST 42: a NON-stream request on the same bedrock route is a MISS -- the
bypass is stream-scoped
+--- main_config
+ env AWS_EC2_METADATA_DISABLED=true;
+--- request
+POST /bedrock-stream/converse
+{"messages":[{"role":"user","content":[{"text":"What is 1+1?"}]}]}
+--- error_code: 200
+--- response_headers
+X-AI-Cache-Status: MISS
+--- response_body eval
+qr/"text"\s*:\s*"Hello!"/
diff --git a/t/plugin/ai-cache.t b/t/plugin/ai-cache.t
index ddc72eb1c..5419d5ef9 100644
--- a/t/plugin/ai-cache.t
+++ b/t/plugin/ai-cache.t
@@ -225,7 +225,7 @@ qr/1 \+ 1 = 2/
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
stripped, so it must not matter")
+ 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 = {} }
@@ -687,7 +687,7 @@ X-AI-Cache-Status: MISS
--- response_body_like eval
qr/1 \+ 1 = 2/
--- error_log
-ai-cache: redis unavailable, fail-open as MISS
+ai-cache: L1 lookup failed, fail-open as MISS