@@ -21,6 +21,7 @@ local key_mod = require("apisix.plugins.ai-cache.key")
2121local binding = require (" apisix.plugins.ai-protocols.binding" )
2222local redis_util = require (" apisix.utils.redis" )
2323local semantic = require (" apisix.plugins.ai-cache.semantic" )
24+ local stream = require (" apisix.plugins.ai-cache.stream" )
2425
2526local ngx = ngx
2627local ngx_null = ngx .null
@@ -81,6 +82,53 @@ local function release(conf, red)
8182end
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+
84132local 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 )
98148end
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"
213253end
214254
222262
223263function _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)
244285end
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 )
277312end
278313
279314
280315function _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
0 commit comments