blob: 47afb6d19cdc1f3f332a95628d18c6711889ef04 [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();
no_shuffle();
add_block_preprocessor(sub {
my ($block) = @_;
if (!defined $block->request) {
$block->set_value("request", "GET /t");
}
my $http_config = $block->http_config // <<_EOC_;
# Mock embedding endpoint: returns a 2-D vector keyed on keywords so
# tests can steer which instance a prompt matches.
server {
server_name mock_embedding;
listen 6797;
default_type 'application/json';
location /v1/embeddings {
content_by_lua_block {
local json = require("cjson.safe")
ngx.req.read_body()
-- the embedding call must not carry the client's headers
ngx.log(ngx.WARN, "embed-recv-cookie:",
ngx.var.http_cookie or "none")
local body = json.decode(ngx.req.get_body_data()) or {}
for _, t in ipairs(body.input or {}) do
if string.lower(t):find("servererror") then
-- an error body that echoes the prompt back, as some
-- providers do -- it must never reach our logs
ngx.status = 500
ngx.say('{"error":"bad input: ', t, ' SENSITIVE-ECHO"}')
return
elseif string.lower(t):find("scalarbody") then
-- 200 with a bare JSON null: decodes to a non-table,
-- which must not be indexed
ngx.status = 200
ngx.say("null")
return
elseif string.lower(t):find("scalarentry") then
-- 200 whose data array holds non-objects: indexing
-- item.index on a number would raise
ngx.status = 200
ngx.say('{"data":[1,2,3]}')
return
elseif string.lower(t):find("naninf") then
-- 200 with a non-finite embedding component
-- (1e999 decodes to inf): must be rejected rather
-- than normalized to NaN and poisoning every score
ngx.status = 200
ngx.say('{"data":[{"index":0,"embedding":[1e999,0]}]}')
return
end
end
local function vec(text)
text = string.lower(text or "")
if text:find("code") or text:find("python") or text:find("debug") then
return {1, 0, 0}
elseif text:find("translate") or text:find("summar") then
return {0, 1, 0}
elseif text:find("weather") or text:find("joke") then
-- the `default` instance's own domain (general chat),
-- so it competes on similarity like any other instance
return {0, 0, 1}
end
-- A prompt matching no instance's domain: its components
-- are distinct and none aligns with an axis, so it clears
-- no high threshold and the request falls back. Distinct
-- values also keep the reported score order deterministic.
return {0.7, 0.5, 0.3}
end
local data = {}
for i, t in ipairs(body.input or {}) do
if string.lower(t):find("malformed") then
-- emit a JSON null embedding (decodes to cjson.null)
-- to exercise fail-open on malformed data
data[i] = { index = i - 1, embedding = json.null }
elseif string.lower(t):find("dimmismatch") then
-- 2-D vector vs the 3-D reference vectors
data[i] = { index = i - 1, embedding = {1, 0} }
else
data[i] = { index = i - 1, embedding = vec(t) }
end
end
ngx.status = 200
ngx.say(json.encode({ data = data }))
}
}
}
# Mock Azure OpenAI embeddings: the deployment and api-version live in
# the URL, exactly as a real Azure endpoint requires.
server {
server_name mock_azure_embedding;
listen 6799;
default_type 'application/json';
location /openai/deployments/emb/embeddings {
content_by_lua_block {
local json = require("cjson.safe")
ngx.req.read_body()
-- prove the request reached the full Azure path with its query
-- (an nginx arg variable cannot express the hyphen)
local args = ngx.req.get_uri_args()
ngx.log(ngx.WARN, "azure-embed-hit api-version=",
args["api-version"] or "none")
local body = json.decode(ngx.req.get_body_data()) or {}
local data = {}
for i, t in ipairs(body.input or {}) do
local v = {0.6, 0.6}
if string.lower(t):find("code") or string.lower(t):find("python") then
v = {1, 0}
end
data[i] = { index = i - 1, embedding = v }
end
ngx.status = 200
ngx.say(json.encode({ data = data }))
}
}
}
# Mock LLM: echoes the model it received so tests can tell which
# instance was selected.
server {
server_name mock_llm;
listen 6798;
default_type 'application/json';
location /v1/chat/completions {
content_by_lua_block {
local json = require("cjson.safe")
ngx.req.read_body()
local body = json.decode(ngx.req.get_body_data()) or {}
ngx.status = 200
ngx.print(body.model or "unknown")
}
}
}
_EOC_
$block->set_value("http_config", $http_config);
my $user_yaml_config = <<_EOC_;
plugins:
- ai-proxy-multi
_EOC_
$block->set_value("extra_yaml_config", $user_yaml_config);
});
run_tests();
__DATA__
=== TEST 1: configure a semantic route (code / translate / fallback)
--- config
location /t {
content_by_lua_block {
-- Build the config as a Lua table and core.json.encode it. A long
-- inline JSON `[[ ]]` block that crosses nginx's config buffer
-- boundary is misparsed by ngx_lua ("missing the closing long
-- bracket"), so we keep the inlined Lua short instead.
local core = require("apisix.core")
local t = require("lib.test_admin").test
local function inst(name, model, examples)
local i = {
name = name, provider = "openai", weight = 1,
auth = { header = { Authorization = "Bearer token" } },
options = { model = model },
override = {
endpoint = "http://127.0.0.1:6798/v1/chat/completions",
},
}
if examples then i.examples = examples end
return i
end
local conf = {
uri = "/anything",
plugins = {
["ai-proxy-multi"] = {
balancer = { algorithm = "semantic" },
semantic_opts = {
embeddings = {
provider = "openai",
model = "text-embedding-3-small",
endpoint = "http://127.0.0.1:6797/v1/embeddings",
auth = { header = { Authorization = "Bearer token" } },
ssl_verify = false,
},
threshold = 0.9,
fallback = "default",
},
instances = {
inst("code", "model-code", {"write python code"}),
inst("cheap", "model-cheap", {"translate this text"}),
inst("default", "model-fallback",
{"what is the weather today", "tell me a joke"}),
},
ssl_verify = false,
},
},
}
local code, body = t('/apisix/admin/routes/1', ngx.HTTP_PUT,
core.json.encode(conf))
if code >= 300 then
ngx.status = code
ngx.say(body)
return
end
ngx.say("passed")
}
}
--- response_body
passed
--- no_error_log
[error]
=== TEST 2: coding prompt routes to the code model
--- request
POST /anything
{"model":"auto","messages":[{"role":"user","content":"help me debug this python code"}]}
--- more_headers
Content-Type: application/json
--- response_body chomp
model-code
--- no_error_log
[error]
=== TEST 3: translation prompt routes to the cheap model
--- request
POST /anything
{"model":"auto","messages":[{"role":"user","content":"please translate this to english"}]}
--- more_headers
Content-Type: application/json
--- response_body chomp
model-cheap
--- no_error_log
[error]
=== TEST 4: unrelated prompt clears no threshold, falls back to the fallback instance
--- request
POST /anything
{"model":"auto","messages":[{"role":"user","content":"recommend a good hotel in paris"}]}
--- more_headers
Content-Type: application/json
--- response_body chomp
model-fallback
--- error_log eval
qr/no instance cleared threshold \(scores: code:0\.\d+,cheap:0\.\d+,default:0\.\d+\)/
--- no_error_log
[error]
=== TEST 5: malformed embedding fails open to the fallback, not the matching instance
--- request
POST /anything
{"model":"auto","messages":[{"role":"user","content":"debug this python code, malformed"}]}
--- more_headers
Content-Type: application/json
--- error_code: 200
--- response_body chomp
model-fallback
--- error_log
semantic routing: query embedding failed
--- no_error_log
[error]
=== TEST 6: reconfigure the route with debugging enabled
--- config
location /t {
content_by_lua_block {
local core = require("apisix.core")
local t = require("lib.test_admin").test
local function inst(name, model, examples)
local i = {
name = name, provider = "openai", weight = 1,
auth = { header = { Authorization = "Bearer token" } },
options = { model = model },
override = {
endpoint = "http://127.0.0.1:6798/v1/chat/completions",
},
}
if examples then i.examples = examples end
return i
end
local conf = {
uri = "/anything",
plugins = {
["ai-proxy-multi"] = {
balancer = { algorithm = "semantic" },
semantic_opts = {
embeddings = {
provider = "openai",
model = "text-embedding-3-small",
endpoint = "http://127.0.0.1:6797/v1/embeddings",
auth = { header = { Authorization = "Bearer token" } },
ssl_verify = false,
},
threshold = 0.9,
fallback = "default",
debugging = true,
},
instances = {
inst("code", "model-code", {"write python code"}),
inst("cheap", "model-cheap", {"translate this text"}),
inst("default", "model-fallback",
{"what is the weather today", "tell me a joke"}),
},
ssl_verify = false,
},
},
}
local code, body = t('/apisix/admin/routes/1', ngx.HTTP_PUT,
core.json.encode(conf))
if code >= 300 then
ngx.status = code
ngx.say(body)
return
end
ngx.say("passed")
}
}
--- response_body
passed
--- no_error_log
[error]
=== TEST 7: debug exposes per-instance scores and the pick via headers
--- request
POST /anything
{"model":"auto","messages":[{"role":"user","content":"help me debug this python code"}]}
--- more_headers
Content-Type: application/json
--- response_body chomp
model-code
--- response_headers_like
X-AI-Semantic-Picked-Instance: code
X-AI-Semantic-Scores: code:1\.\d+(,\w+:0\.\d+){2}
--- no_error_log
[error]
=== TEST 8: embedding dimension mismatch fails open to the fallback
--- request
POST /anything
{"model":"auto","messages":[{"role":"user","content":"dimmismatch python code"}]}
--- more_headers
Content-Type: application/json
--- error_code: 200
--- response_body chomp
model-fallback
--- error_log
embedding dimension mismatch
--- no_error_log
[error]
=== TEST 9: multimodal content array extracts text parts for routing
--- request
POST /anything
{"model":"auto","messages":[{"role":"user","content":[
{"type":"text","text":"write python code"},
{"type":"image_url","image_url":{"url":"http://x/y.png"}}
]}]}
--- more_headers
Content-Type: application/json
--- response_body chomp
model-code
--- no_error_log
[error]
=== TEST 10: client headers are not forwarded to the embedding endpoint
--- request
POST /anything
{"model":"auto","messages":[{"role":"user","content":"help me debug this python code"}]}
--- more_headers
Content-Type: application/json
Cookie: session=super-secret
--- response_body chomp
model-code
--- error_log
embed-recv-cookie:none
--- no_error_log
embed-recv-cookie:session=super-secret
=== TEST 11: non-200 fails open to the fallback, without logging the upstream body
--- request
POST /anything
{"model":"auto","messages":[{"role":"user","content":"servererror python code"}]}
--- more_headers
Content-Type: application/json
--- error_code: 200
--- response_body chomp
model-fallback
--- error_log
embedding endpoint returned status 500
--- no_error_log
SENSITIVE-ECHO
=== TEST 12: 200 with a non-table body fails open instead of raising
--- request
POST /anything
{"model":"auto","messages":[{"role":"user","content":"scalarbody python code"}]}
--- more_headers
Content-Type: application/json
--- error_code: 200
--- response_body chomp
model-fallback
--- error_log
invalid embedding response
--- no_error_log
[error]
=== TEST 13: 200 whose data array holds non-objects fails open instead of raising
--- request
POST /anything
{"model":"auto","messages":[{"role":"user","content":"scalarentry python code"}]}
--- more_headers
Content-Type: application/json
--- error_code: 200
--- response_body chomp
model-fallback
--- error_log
invalid embedding entry at index 0
--- no_error_log
[error]
=== TEST 14: reference embedding failure fails open to the fallback
--- config
location /t {
content_by_lua_block {
local core = require("apisix.core")
local t = require("lib.test_admin").test
local function inst(name, model, examples)
local i = {
name = name, provider = "openai", weight = 1,
auth = { header = { Authorization = "Bearer token" } },
options = { model = model },
override = {
endpoint = "http://127.0.0.1:6798/v1/chat/completions",
},
}
if examples then i.examples = examples end
return i
end
local conf = {
uri = "/anything",
plugins = {
["ai-proxy-multi"] = {
balancer = { algorithm = "semantic" },
semantic_opts = {
embeddings = {
provider = "openai",
model = "text-embedding-3-small",
endpoint = "http://127.0.0.1:6797/v1/embeddings",
auth = { header = { Authorization = "Bearer token" } },
ssl_verify = false,
},
threshold = 0.9,
fallback = "default",
},
instances = {
-- embedding this instance's examples makes the mock
-- fail the whole reference batch
inst("code", "model-code", {"servererror example"}),
inst("default", "model-fallback",
{"what is the weather today", "tell me a joke"}),
},
ssl_verify = false,
},
},
}
local code, body = t('/apisix/admin/routes/1', ngx.HTTP_PUT,
core.json.encode(conf))
if code >= 300 then
ngx.status = code
ngx.say(body)
return
end
ngx.say("passed")
}
}
--- response_body
passed
--- no_error_log
[error]
=== TEST 15: request on the broken-reference route still routes to the fallback
--- request
POST /anything
{"model":"auto","messages":[{"role":"user","content":"help me debug this python code"}]}
--- more_headers
Content-Type: application/json
--- error_code: 200
--- response_body chomp
model-fallback
--- error_log
failed to fetch reference embeddings
--- no_error_log
[error]
=== TEST 16: azure-openai embeddings route via the full deployment URL
--- config
location /t {
content_by_lua_block {
local core = require("apisix.core")
local t = require("lib.test_admin").test
local function inst(name, model, examples)
local i = {
name = name, provider = "openai", weight = 1,
auth = { header = { Authorization = "Bearer token" } },
options = { model = model },
override = {
endpoint = "http://127.0.0.1:6798/v1/chat/completions",
},
}
if examples then i.examples = examples end
return i
end
local conf = {
uri = "/anything",
plugins = {
["ai-proxy-multi"] = {
balancer = { algorithm = "semantic" },
semantic_opts = {
embeddings = {
provider = "azure-openai",
model = "text-embedding-3-small",
endpoint = "http://127.0.0.1:6799/openai/deployments/emb"
.. "/embeddings?api-version=2024-02-01",
auth = { header = { ["api-key"] = "azure-key" } },
ssl_verify = false,
},
threshold = 0.9,
fallback = "default",
},
instances = {
inst("code", "model-code", {"write python code"}),
inst("default", "model-fallback",
{"what is the weather today", "tell me a joke"}),
},
ssl_verify = false,
},
},
}
local code, body = t('/apisix/admin/routes/1', ngx.HTTP_PUT,
core.json.encode(conf))
if code >= 300 then
ngx.status = code
ngx.say(body)
return
end
ngx.say("passed")
}
}
--- response_body
passed
--- no_error_log
[error]
=== TEST 17: azure embeddings request reaches the deployment path with api-version
--- request
POST /anything
{"model":"auto","messages":[{"role":"user","content":"help me debug this python code"}]}
--- more_headers
Content-Type: application/json
--- response_body chomp
model-code
--- error_log
azure-embed-hit api-version=2024-02-01
--- no_error_log
[error]
=== TEST 18: no fallback configured -- fallback is the first instance
--- config
location /t {
content_by_lua_block {
local core = require("apisix.core")
local t = require("lib.test_admin").test
local function inst(name, model, examples)
return {
name = name, provider = "openai", weight = 1,
auth = { header = { Authorization = "Bearer token" } },
options = { model = model },
override = {
endpoint = "http://127.0.0.1:6798/v1/chat/completions",
},
examples = examples,
}
end
local conf = {
uri = "/anything",
plugins = {
["ai-proxy-multi"] = {
balancer = { algorithm = "semantic" },
semantic_opts = {
embeddings = {
provider = "openai",
model = "text-embedding-3-small",
endpoint = "http://127.0.0.1:6797/v1/embeddings",
auth = { header = { Authorization = "Bearer token" } },
ssl_verify = false,
},
threshold = 0.9,
},
instances = {
inst("first", "model-first", {"write python code"}),
inst("second", "model-second", {"translate this text"}),
},
ssl_verify = false,
},
},
}
local code, body = t('/apisix/admin/routes/1', ngx.HTTP_PUT,
core.json.encode(conf))
if code >= 300 then
ngx.status = code
ngx.say(body)
return
end
ngx.say("passed")
}
}
--- response_body
passed
--- no_error_log
[error]
=== TEST 19: unmatched prompt falls back to the first instance
--- request
POST /anything
{"model":"auto","messages":[{"role":"user","content":"what is the weather today"}]}
--- more_headers
Content-Type: application/json
--- response_body chomp
model-first
--- error_log
no instance cleared threshold
--- no_error_log
[error]
=== TEST 20: per-instance threshold overrides the global one
--- config
location /t {
content_by_lua_block {
local core = require("apisix.core")
local t = require("lib.test_admin").test
local conf = {
uri = "/anything",
plugins = {
["ai-proxy-multi"] = {
-- the global threshold is permissive (0.1); the code
-- instance raises its own bar to 0.9, so a prompt that
-- partially matches it (~0.77) clears the global threshold
-- but not the instance's, and the request goes to default
balancer = { algorithm = "semantic" },
semantic_opts = {
embeddings = {
provider = "openai",
model = "text-embedding-3-small",
endpoint = "http://127.0.0.1:6797/v1/embeddings",
auth = { header = { Authorization = "Bearer token" } },
ssl_verify = false,
},
threshold = 0.1,
fallback = "default",
},
instances = {
{
name = "code", provider = "openai", weight = 1,
auth = { header = { Authorization = "Bearer token" } },
options = { model = "model-code" },
override = {
endpoint = "http://127.0.0.1:6798/v1/chat/completions",
},
examples = {"write python code"},
threshold = 0.9,
},
{
name = "default", provider = "openai", weight = 1,
auth = { header = { Authorization = "Bearer token" } },
options = { model = "model-fallback" },
override = {
endpoint = "http://127.0.0.1:6798/v1/chat/completions",
},
examples = {"what is the weather today", "tell me a joke"},
},
},
ssl_verify = false,
},
},
}
local code, body = t('/apisix/admin/routes/1', ngx.HTTP_PUT,
core.json.encode(conf))
if code >= 300 then
ngx.status = code
ngx.say(body)
return
end
ngx.say("passed")
}
}
--- response_body
passed
--- no_error_log
[error]
=== TEST 21: a prompt below the per-instance threshold reaches the fallback
--- request
POST /anything
{"model":"auto","messages":[{"role":"user","content":"recommend a good hotel in paris"}]}
--- more_headers
Content-Type: application/json
--- response_body chomp
model-fallback
--- no_error_log
[error]
=== TEST 22: configure a semantic route on the Responses API path
--- config
location /t {
content_by_lua_block {
local core = require("apisix.core")
local t = require("lib.test_admin").test
local function inst(name, model, examples)
local i = {
name = name, provider = "openai", weight = 1,
auth = { header = { Authorization = "Bearer token" } },
options = { model = model },
override = {
endpoint = "http://127.0.0.1:6798/v1/chat/completions",
},
}
if examples then i.examples = examples end
return i
end
local conf = {
uri = "/llm/v1/responses",
plugins = {
["ai-proxy-multi"] = {
balancer = { algorithm = "semantic" },
semantic_opts = {
embeddings = {
provider = "openai",
model = "text-embedding-3-small",
endpoint = "http://127.0.0.1:6797/v1/embeddings",
auth = { header = { Authorization = "Bearer token" } },
ssl_verify = false,
},
threshold = 0.9,
fallback = "default",
debugging = true,
},
instances = {
inst("code", "model-code", {"write python code"}),
inst("cheap", "model-cheap", {"translate this text"}),
inst("default", "model-fallback",
{"what is the weather today", "tell me a joke"}),
},
ssl_verify = false,
},
},
}
local code, body = t('/apisix/admin/routes/2', ngx.HTTP_PUT,
core.json.encode(conf))
if code >= 300 then
ngx.status = code
ngx.say(body)
return
end
ngx.say("passed")
}
}
--- response_body
passed
--- no_error_log
[error]
=== TEST 23: a Responses request (body.input) is routed, not fallen back
--- request
POST /llm/v1/responses
{"model":"auto","input":"help me debug this python code"}
--- more_headers
Content-Type: application/json
--- response_headers_like
X-AI-Semantic-Picked-Instance: code
X-AI-Semantic-Scores: code:1\.\d+(,\w+:0\.\d+){2}
--- no_error_log
[error]
=== TEST 24: configure a semantic route on the Anthropic Messages path
--- config
location /t {
content_by_lua_block {
local core = require("apisix.core")
local t = require("lib.test_admin").test
local function inst(name, model, examples)
local i = {
name = name, provider = "openai", weight = 1,
auth = { header = { Authorization = "Bearer token" } },
options = { model = model },
override = {
endpoint = "http://127.0.0.1:6798/v1/chat/completions",
},
}
if examples then i.examples = examples end
return i
end
local conf = {
uri = "/llm/v1/messages",
plugins = {
["ai-proxy-multi"] = {
balancer = { algorithm = "semantic" },
semantic_opts = {
embeddings = {
provider = "openai",
model = "text-embedding-3-small",
endpoint = "http://127.0.0.1:6797/v1/embeddings",
auth = { header = { Authorization = "Bearer token" } },
ssl_verify = false,
},
threshold = 0.9,
fallback = "default",
debugging = true,
},
instances = {
inst("code", "model-code", {"write python code"}),
inst("cheap", "model-cheap", {"translate this text"}),
inst("default", "model-fallback",
{"what is the weather today", "tell me a joke"}),
},
ssl_verify = false,
},
},
}
local code, body = t('/apisix/admin/routes/3', ngx.HTTP_PUT,
core.json.encode(conf))
if code >= 300 then
ngx.status = code
ngx.say(body)
return
end
ngx.say("passed")
}
}
--- response_body
passed
--- no_error_log
[error]
=== TEST 25: an Anthropic request (body.system + content) is routed by the last user turn
--- request
POST /llm/v1/messages
{"model":"auto","system":"you are a helpful assistant","messages":[{"role":"user","content":"help me debug this python code"}]}
--- more_headers
Content-Type: application/json
--- response_headers_like
X-AI-Semantic-Picked-Instance: code
X-AI-Semantic-Scores: code:1\.\d+(,\w+:0\.\d+){2}
=== TEST 26: configure a route to exercise the non-finite-component guard
--- config
location /t {
content_by_lua_block {
local core = require("apisix.core")
local t = require("lib.test_admin").test
local conf = {
uri = "/anything",
plugins = {
["ai-proxy-multi"] = {
balancer = { algorithm = "semantic" },
semantic_opts = {
embeddings = {
provider = "openai",
model = "text-embedding-3-small",
endpoint = "http://127.0.0.1:6797/v1/embeddings",
auth = { header = { Authorization = "Bearer token" } },
ssl_verify = false,
},
threshold = 0.9,
fallback = "default",
},
instances = {
{
name = "code", provider = "openai", weight = 1,
auth = { header = { Authorization = "Bearer token" } },
options = { model = "model-code" },
override = {
endpoint = "http://127.0.0.1:6798/v1/chat/completions",
},
examples = {"write python code"},
},
{
name = "default", provider = "openai", weight = 1,
auth = { header = { Authorization = "Bearer token" } },
options = { model = "model-fallback" },
override = {
endpoint = "http://127.0.0.1:6798/v1/chat/completions",
},
examples = {"what is the weather today", "tell me a joke"},
},
},
ssl_verify = false,
},
},
}
local code, body = t('/apisix/admin/routes/1', ngx.HTTP_PUT,
core.json.encode(conf))
if code >= 300 then
ngx.status = code
ngx.say(body)
return
end
ngx.say("passed")
}
}
--- response_body
passed
--- no_error_log
[error]
=== TEST 27: a non-finite embedding component fails open instead of poisoning scores
--- request
POST /anything
{"model":"auto","messages":[{"role":"user","content":"naninf please route me"}]}
--- more_headers
Content-Type: application/json
--- error_code: 200
--- response_body chomp
model-fallback
--- error_log
non-numeric embedding component
--- no_error_log
[error]