blob: 6a18e9b0271697f40bfb38103e33d9f43e3bbf5d [file]
#
# 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!"/