Skip to content

Commit a2d34cd

Browse files
authored
feat(ai-cache): add streaming support with format tagging (#13644)
1 parent b83f323 commit a2d34cd

12 files changed

Lines changed: 1220 additions & 140 deletions

File tree

apisix/plugins/ai-cache.lua

Lines changed: 132 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ local key_mod = require("apisix.plugins.ai-cache.key")
2121
local binding = require("apisix.plugins.ai-protocols.binding")
2222
local redis_util = require("apisix.utils.redis")
2323
local semantic = require("apisix.plugins.ai-cache.semantic")
24+
local stream = require("apisix.plugins.ai-cache.stream")
2425

2526
local ngx = ngx
2627
local ngx_null = ngx.null
@@ -81,6 +82,53 @@ local function release(conf, red)
8182
end
8283

8384

85+
-- Run fn(red) on a pooled connection: released on success, closed when fn
86+
-- returns an error or throws. Returns fn's result, or (nil, err) on any failure.
87+
local function with_redis(conf, fn)
88+
local red, err = redis_util.new(conf)
89+
if not red then
90+
return nil, err
91+
end
92+
local ok, res, ferr = pcall(fn, red)
93+
if not ok or ferr then
94+
red:close()
95+
return nil, not ok and res or ferr
96+
end
97+
release(conf, red)
98+
return res
99+
end
100+
101+
102+
-- fail-open: a cache-backend or embedding failure must never break the
103+
-- request; log it and treat the lookup as a MISS.
104+
local function fail_open(ctx, what, err)
105+
core.log.warn("ai-cache: ", what, ", fail-open as MISS: ", err)
106+
ctx.ai_cache_status = "MISS"
107+
end
108+
109+
110+
-- The L1 stored value; encoded only here so the shape has one home.
111+
local function encode_entry(body, created_at, format)
112+
return core.json.encode({ body = body, created_at = created_at, format = format })
113+
end
114+
115+
116+
-- Best-effort L2 -> L1 backfill under this request's L1 key, carrying
117+
-- created_at and format so either layer replays the hit identically.
118+
local function backfill_l1(conf, ctx, red, hit)
119+
local envelope = encode_entry(hit.body, hit.created_at, hit.format)
120+
if not envelope then
121+
core.log.warn("ai-cache: L1 backfill skipped: json.encode returned nil")
122+
return
123+
end
124+
local ok, err = red:set(ctx.ai_cache_key, envelope,
125+
"EX", (conf.exact and conf.exact.ttl) or DEFAULT_TTL)
126+
if not ok then
127+
core.log.warn("ai-cache: L1 backfill SET failed: ", err)
128+
end
129+
end
130+
131+
84132
local function serve_hit(conf, ctx, cached, similarity)
85133
local status = "HIT"
86134
ctx.ai_cache_status = status
@@ -93,7 +141,9 @@ local function serve_hit(conf, ctx, cached, similarity)
93141
str_format("%.4f", similarity))
94142
end
95143
end
96-
core.response.set_header("Content-Type", "application/json")
144+
core.response.set_header("Content-Type",
145+
cached.format == stream.FORMAT_SSE and "text/event-stream"
146+
or "application/json")
97147
return core.response.exit(200, cached.body)
98148
end
99149

@@ -111,10 +161,11 @@ function _M.access(conf, ctx)
111161
return
112162
end
113163

114-
-- Streaming responses are not cached in PR-1 (SSE replay is a later
115-
-- increment). ai-proxy (higher priority) has already classified the
116-
-- request, so bypass before doing any work.
117-
if ctx.var.request_type == "ai_stream" then
164+
-- A stream on a non-SSE wire framing (bedrock's aws-eventstream) can never
165+
-- be captured or replayed, so the lookup would be a guaranteed-miss redis
166+
-- GET on every request: bypass before doing any work.
167+
if ctx.var.request_type == "ai_stream"
168+
and not stream.provider_capturable(ctx.picked_ai_instance) then
118169
ctx.ai_cache_status = "BYPASS"
119170
return
120171
end
@@ -137,78 +188,67 @@ function _M.access(conf, ctx)
137188

138189
ctx.ai_cache_fingerprint = key_mod.fingerprint(ctx, body)
139190
ctx.ai_cache_key = key_mod.build(conf, ctx, ctx.ai_cache_fingerprint)
140-
-- Remember which instance the fingerprint was computed for. ai-proxy-multi
141-
-- may fall back to a different instance in before_proxy; the log phase uses
142-
-- this to avoid writing that fallback response under the original key.
191+
-- which instance the fingerprint was computed for; log() checks it so a
192+
-- fallback instance's response is never written under this key
143193
ctx.ai_cache_picked_at_access = ctx.picked_ai_instance
144194

145-
local red
146-
red, err = redis_util.new(conf)
147-
if not red then
148-
-- fail-open: never let a cache-backend outage break the request.
149-
core.log.warn("ai-cache: redis unavailable, fail-open as MISS: ", err)
150-
ctx.ai_cache_status = "MISS"
151-
return
152-
end
153-
154-
local res
155-
res, err = red:get(ctx.ai_cache_key)
156-
if err then
157-
red:close()
158-
core.log.warn("ai-cache: redis get failed, fail-open as MISS: ", err)
159-
ctx.ai_cache_status = "MISS"
160-
return
161-
end
162-
if res ~= nil and res ~= ngx_null then
163-
local cached = core.json.decode(res)
164-
if cached and cached.body then
165-
release(conf, red)
166-
return serve_hit(conf, ctx, cached)
195+
local cached
196+
cached, err = with_redis(conf, function(red)
197+
local res, gerr = red:get(ctx.ai_cache_key)
198+
if gerr then
199+
return nil, gerr
200+
end
201+
if res == nil or res == ngx_null then
202+
return nil
203+
end
204+
local entry = core.json.decode(res)
205+
if entry and entry.body then
206+
return entry
167207
end
168208
core.log.warn("ai-cache: discarding malformed cache entry for ", ctx.ai_cache_key)
209+
return nil
210+
end)
211+
if err then
212+
return fail_open(ctx, "L1 lookup failed", err)
213+
end
214+
if cached then
215+
return serve_hit(conf, ctx, cached)
169216
end
170217

171-
-- L1 miss -> L2 semantic lookup. Release the L1 connection before
172-
-- embed_query()'s HTTP call so the pool isn't pinned across the embedding
173-
-- round-trip; re-acquire for the vector search. pcall keeps throws fail-open.
218+
-- L1 miss -> L2 semantic lookup, in its own connection scope so the pool
219+
-- isn't pinned across embed_query()'s HTTP round-trip.
174220
if has_layer(conf, "semantic") and conf.semantic then
175-
release(conf, red)
176-
177221
local ok, vec = pcall(semantic.embed_query, conf, ctx, body)
178222
if not ok then
179-
core.log.warn("ai-cache: semantic embed error, fail-open as MISS: ", vec)
180-
vec = nil
223+
fail_open(ctx, "semantic embed error", vec)
181224
-- prevent log() from scheduling a write with partial/bad state
182225
ctx.ai_cache_embedding = nil
226+
return
183227
end
184228

185229
if vec then
186-
local sred
187-
sred, err = redis_util.new(conf)
188-
if not sred then
189-
core.log.warn("ai-cache: redis unavailable for semantic search, ",
190-
"fail-open as MISS: ", err)
191-
else
192-
local sok, hit = pcall(semantic.search, sred, conf, ctx, vec)
193-
if not sok then
194-
sred:close()
195-
core.log.warn("ai-cache: semantic search error, fail-open as MISS: ", hit)
196-
ctx.ai_cache_embedding = nil
197-
else
198-
release(conf, sred)
199-
if hit then
200-
return serve_hit(conf, ctx,
201-
{ body = hit.body, created_at = hit.created_at }, hit.similarity)
230+
local hit
231+
hit, err = with_redis(conf, function(red)
232+
local h = semantic.search(red, conf, ctx, vec)
233+
if h then
234+
local bok, berr = pcall(backfill_l1, conf, ctx, red, h)
235+
if not bok then
236+
core.log.warn("ai-cache: L1 backfill error: ", berr)
202237
end
203238
end
239+
return h
240+
end)
241+
if err then
242+
fail_open(ctx, "semantic search failed", err)
243+
ctx.ai_cache_embedding = nil
244+
return
245+
end
246+
if hit then
247+
return serve_hit(conf, ctx, hit, hit.similarity)
204248
end
205249
end
206-
207-
ctx.ai_cache_status = "MISS"
208-
return
209250
end
210251

211-
release(conf, red)
212252
ctx.ai_cache_status = "MISS"
213253
end
214254

@@ -222,7 +262,8 @@ end
222262

223263
function _M.body_filter(conf, ctx)
224264
-- only a MISS gets written back; HIT exited in access, BYPASS opts out.
225-
if ctx.ai_cache_status ~= "MISS" or ctx.ai_cache_oversized then
265+
if ctx.ai_cache_status ~= "MISS" or ctx.ai_cache_oversized
266+
or not stream.capturable(ctx) then
226267
return
227268
end
228269
local chunk = ngx.arg[1]
@@ -244,64 +285,63 @@ function _M.body_filter(conf, ctx)
244285
end
245286

246287

247-
-- The response-capturing phases (body_filter / log) run in contexts where
248-
-- cosockets are disabled, so the Redis write is deferred to a 0-delay timer
249-
-- (timers run in a light thread where cosockets are allowed).
250-
-- l2 (optional) = { partition, embedding, dim, fingerprint, ttl } for L2 write.
251-
local function write_to_cache(premature, conf, cache_key, response_body, l2)
288+
-- body_filter/log cannot use cosockets, so the Redis write runs in a 0-delay
289+
-- timer. l2 (optional) = { partition, embedding, dim, fingerprint, ttl }.
290+
local function write_to_cache(premature, conf, cache_key, response_body, l2, format)
252291
if premature then
253292
return
254293
end
255-
local red, err = redis_util.new(conf)
256-
if not red then
257-
core.log.warn("ai-cache: redis unavailable on write: ", err)
258-
return
259-
end
260-
local envelope = core.json.encode({ body = response_body, created_at = ngx.time() })
261-
local ttl = (conf.exact and conf.exact.ttl) or DEFAULT_TTL
262-
local ok
263-
ok, err = red:set(cache_key, envelope, "EX", ttl)
264-
if not ok then
265-
red:close()
266-
core.log.warn("ai-cache: redis set failed: ", err)
267-
return
268-
end
269-
if l2 then
270-
l2.created_at = ngx.time()
271-
local wok, werr = pcall(semantic.write, red, conf, l2, response_body)
272-
if not wok then
273-
core.log.warn("ai-cache: semantic write error: ", werr)
294+
local now = ngx.time()
295+
local envelope = encode_entry(response_body, now, format)
296+
local _, err = with_redis(conf, function(red)
297+
local ok, serr = red:set(cache_key, envelope, "EX",
298+
(conf.exact and conf.exact.ttl) or DEFAULT_TTL)
299+
if not ok then
300+
return nil, serr
301+
end
302+
if l2 then
303+
l2.created_at = now
304+
l2.format = format
305+
semantic.write(red, conf, l2, response_body)
274306
end
307+
return true
308+
end)
309+
if err then
310+
core.log.warn("ai-cache: cache write failed: ", err)
275311
end
276-
release(conf, red)
277312
end
278313

279314

280315
function _M.log(conf, ctx)
281316
if ctx.ai_cache_status ~= "MISS" or not ctx.ai_cache_fingerprint then
282317
return
283318
end
284-
-- ai-proxy-multi may reassign the picked instance on fallback/retry during
285-
-- before_proxy. The frozen fingerprint identifies the ORIGINAL instance, so a
286-
-- response actually produced by a different (fallback) instance must not be
287-
-- written under it -- that would replay the wrong instance's response on a
288-
-- later hit.
319+
-- the fingerprint identifies the instance picked at access time; a
320+
-- fallback/retry response from another instance must not be cached under it
289321
if ctx.picked_ai_instance ~= ctx.ai_cache_picked_at_access then
290322
return
291323
end
292324
if ngx.status ~= 200 then
293325
return
294326
end
327+
if ctx.ai_stream_aborted then
328+
return
329+
end
295330
local buf = ctx.ai_cache_buf
296331
if not buf or buf.bytes == 0 then
297332
return
298333
end
299334
local response_body = concat(buf, "", 1, buf.n)
300335

336+
local format = stream.capture_format(ctx, response_body)
337+
if not format then
338+
return
339+
end
340+
301341
local cache_key = key_mod.build(conf, ctx, ctx.ai_cache_fingerprint)
302342

303-
-- Build the L2 doc from ctx fields stashed by semantic.embed_query(); the
304-
-- embedding is only set on a successful embed, so a nil check guards the write.
343+
-- L2 doc from ctx fields stashed by semantic.embed_query(); the embedding
344+
-- is only set on a successful embed.
305345
local l2
306346
if has_layer(conf, "semantic") and ctx.ai_cache_embedding then
307347
l2 = {
@@ -313,7 +353,8 @@ function _M.log(conf, ctx)
313353
}
314354
end
315355

316-
local ok, err = ngx.timer.at(0, write_to_cache, conf, cache_key, response_body, l2)
356+
local ok, err = ngx.timer.at(0, write_to_cache, conf, cache_key,
357+
response_body, l2, format)
317358
if not ok then
318359
core.log.warn("ai-cache: failed to schedule cache write: ", err)
319360
end

apisix/plugins/ai-cache/key.lua

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,8 @@ local function build_repr(ctx, body, messages)
6666
params[k] = v
6767
end
6868
end
69+
local proto = ctx.ai_client_protocol and protocols.get(ctx.ai_client_protocol)
70+
params.stream = (proto and proto.is_streaming(body)) == true
6971

7072
return {
7173
client = {
@@ -111,15 +113,6 @@ function _M.fingerprint(ctx, body)
111113
end
112114

113115

114-
-- Returns the SHA-256 hex digest of the effective context with message text
115-
-- removed. Queries that differ only in phrasing (same model/params/instance)
116-
-- share one fingerprint, enabling semantic deduplication without storing the
117-
-- raw prompt.
118-
function _M.context_fingerprint(ctx, body)
119-
return hex_digest(core.json.canonical_encode(build_repr(ctx, body, nil)))
120-
end
121-
122-
123116
-- Percent-encode "%", ":" and "=" (in that order) in scope values so a request-controlled
124117
-- include_vars value can't shift "name=value:" boundaries to forge another scope.
125118
local function esc(v)

apisix/plugins/ai-cache/semantic.lua

Lines changed: 6 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -282,7 +282,8 @@ end
282282

283283
-- Phase 2 of the L2 lookup: vector search over a caller-owned connection
284284
-- acquired AFTER embed_query() (so the pool isn't pinned across embedding).
285-
-- Returns a hit {body, created_at, similarity} on a >=threshold match, else nil.
285+
-- Returns a hit {body, created_at, format, similarity} on a >=threshold match,
286+
-- else nil. The L1 backfill of a hit is the caller's job (ai-cache.lua owns L1).
286287
function _M.search(red, conf, ctx, vec)
287288
local sem = conf.semantic
288289
local target = redis_target(conf)
@@ -309,26 +310,13 @@ function _M.search(red, conf, ctx, vec)
309310
return nil
310311
end
311312

312-
-- L2 -> L1 backfill, carrying the L2 entry's original created_at so Age is
313-
-- consistent whether the next hit is served from L1 or L2. A real semantic
314-
-- hit must be served regardless — only the backfill SET is skipped on error.
315-
local envelope = core.json.encode({ body = hit.response, created_at = hit.created_at })
316-
if not envelope then
317-
core.log.warn("ai-cache: L1 backfill skipped: json.encode returned nil")
318-
else
319-
local exact_ttl = (conf.exact and conf.exact.ttl) or 3600
320-
local bok, berr = red:set(ctx.ai_cache_key, envelope, "EX", exact_ttl)
321-
if not bok then
322-
core.log.warn("ai-cache: L1 backfill SET failed: ", berr)
323-
end
324-
end
325-
326-
return { body = hit.response, created_at = hit.created_at, similarity = similarity }
313+
return { body = hit.response, created_at = hit.created_at,
314+
format = hit.format, similarity = similarity }
327315
end
328316

329317

330318
-- Called from the write-back timer (after the L1 SET) with a still-open `red`.
331-
-- l2 = { partition, embedding, dim, fingerprint, ttl, created_at }
319+
-- l2 = { partition, embedding, dim, fingerprint, ttl, created_at, format }
332320
function _M.write(red, conf, l2, response_body)
333321
if not l2 or not l2.embedding then
334322
return
@@ -347,6 +335,7 @@ function _M.write(red, conf, l2, response_body)
347335
embedding = vs.pack_float32(l2.embedding),
348336
response = response_body,
349337
created_at = l2.created_at,
338+
format = l2.format,
350339
}, l2.ttl)
351340
if not ok then
352341
core.log.warn("ai-cache: L2 upsert failed: ", err)

0 commit comments

Comments
 (0)