Skip to content

Commit e81a705

Browse files
committed
feat(ai-proxy-multi): add semantic load-balancing algorithm
Add a `semantic` balancer algorithm to `ai-proxy-multi` that picks an LLM instance by the semantic similarity between the request prompt and each instance's configured `examples`, instead of by weight or hash. Each non-catchall instance declares `examples` describing the prompts it should serve. On the request path the plugin embeds those examples and the incoming prompt via a configurable OpenAI-compatible embedding endpoint, computes cosine similarity, aggregates per-instance scores, and routes to the highest-scoring instance that clears its threshold. When no instance clears its threshold — or embedding fails for any reason — the request fails open to the `catchall` instance (else the first instance), so a request always has a target and never 500s on the routing path. Reference vectors are embedded once per config version and cached; only the prompt is embedded per request. No vector database is required. New config: `balancer.algorithm=semantic`, `balancer.threshold`, `balancer.aggregation`, `balancer.expose_scores`, per-instance `examples` / `threshold` / `catchall`, and an `embeddings` block.
1 parent c7a76b2 commit e81a705

12 files changed

Lines changed: 1462 additions & 10 deletions

File tree

apisix/plugins/ai-providers/azure-openai.lua

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ return require("apisix.plugins.ai-providers.base").new(
3232
path = "/completions",
3333
rewrite_request_body = rewrite_chat_request_body,
3434
},
35+
["openai-embeddings"] = { path = "/embeddings" },
3536
},
3637
}
3738
)

apisix/plugins/ai-providers/base.lua

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,8 @@ function _M.build_request(self, ctx, conf, request_body, opts)
188188
.. "includes a path", 400
189189
end
190190

191-
local headers = transport_http.construct_forward_headers(auth.header or {}, ctx)
191+
local headers = transport_http.construct_forward_headers(auth.header or {}, ctx,
192+
opts.skip_client_headers)
192193
if opts.host_header then
193194
headers["Host"] = opts.host_header
194195
end

apisix/plugins/ai-proxy-multi.lua

Lines changed: 195 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,12 +33,16 @@ local ngx_now = ngx.now
3333

3434
local require = require
3535
local pcall = pcall
36+
local error = error
37+
local tostring = tostring
3638
local ipairs = ipairs
3739
local type = type
3840
local string = string
3941
local url = require("socket.url")
4042

4143
local priority_balancer = require("apisix.balancer.priority")
44+
local semantic = require("apisix.plugins.ai-proxy.semantic")
45+
local embedding = require("apisix.plugins.ai-proxy.embedding")
4246
local endpoint_regex = "^(https?)://([^:/]+):?(%d*)/?.*$"
4347

4448
local pickers = {}
@@ -48,6 +52,11 @@ local lrucache_server_picker = core.lrucache.new({
4852
local lrucache_health_status = core.lrucache.new({
4953
ttl = 300, count = 256
5054
})
55+
-- Keyed by route + conf version, so config changes invalidate immediately;
56+
-- the long ttl just avoids re-embedding references on unchanged config.
57+
local lrucache_semantic_vectors = core.lrucache.new({
58+
ttl = 3600, count = 256
59+
})
5160

5261
local plugin_name = "ai-proxy-multi"
5362
local _M = {
@@ -156,7 +165,41 @@ function _M.check_schema(conf)
156165
end
157166
end
158167

159-
return ok
168+
if algo == "semantic" then
169+
if not conf.embeddings then
170+
return false, "must configure `embeddings` when balancer algorithm is semantic"
171+
end
172+
if conf.embeddings.provider == "azure-openai" and not conf.embeddings.endpoint then
173+
return false, "must configure `embeddings.endpoint` when embeddings " ..
174+
"provider is azure-openai"
175+
end
176+
local catchall_count = 0
177+
for _, instance in ipairs(conf.instances) do
178+
if instance.catchall then
179+
catchall_count = catchall_count + 1
180+
else
181+
local has_example = false
182+
if instance.examples then
183+
for _, ex in ipairs(instance.examples) do
184+
if type(ex) == "string" and ex ~= "" then
185+
has_example = true
186+
break
187+
end
188+
end
189+
end
190+
if not has_example then
191+
return false, "instance '" .. (instance.name or "?") ..
192+
"': must configure non-empty `examples` for the semantic " ..
193+
"algorithm unless `catchall` is set"
194+
end
195+
end
196+
end
197+
if catchall_count > 1 then
198+
return false, "at most one instance may be marked `catchall`"
199+
end
200+
end
201+
202+
return true
160203
end
161204

162205

@@ -598,9 +641,159 @@ local function pick_target(ctx, conf, ups_tab)
598641
end
599642

600643

644+
local function extract_last_user_message()
645+
local body = core.request.get_json_request_body_table()
646+
if not body or type(body.messages) ~= "table" then
647+
return nil
648+
end
649+
for i = #body.messages, 1, -1 do
650+
local m = body.messages[i]
651+
if type(m) == "table" and m.role == "user" then
652+
local content = m.content
653+
if type(content) == "string" then
654+
return content
655+
elseif type(content) == "table" then
656+
-- multimodal content: concatenate the text parts so routing
657+
-- still works for {type=text|image_url,...} arrays.
658+
local parts = {}
659+
for _, p in ipairs(content) do
660+
if type(p) == "table" and p.type == "text"
661+
and type(p.text) == "string" then
662+
parts[#parts + 1] = p.text
663+
end
664+
end
665+
if #parts > 0 then
666+
return table_concat(parts, " ")
667+
end
668+
end
669+
end
670+
end
671+
return nil
672+
end
673+
674+
675+
-- Embed every instance's examples in one batch and group the normalized
676+
-- reference vectors by instance name. Raises on embedding failure so the
677+
-- lrucache below does not cache a bad result.
678+
local function build_instance_vectors(conf)
679+
local texts = {}
680+
local owners = {}
681+
for _, inst in ipairs(conf.instances) do
682+
if inst.examples then
683+
for _, ex in ipairs(inst.examples) do
684+
texts[#texts + 1] = ex
685+
owners[#texts] = inst.name
686+
end
687+
end
688+
end
689+
690+
local vecs, err = embedding.fetch(conf.embeddings, texts)
691+
if not vecs then
692+
error("failed to fetch reference embeddings: " .. tostring(err))
693+
end
694+
695+
local by_instance = {}
696+
for i, v in ipairs(vecs) do
697+
local name = owners[i]
698+
if name then
699+
by_instance[name] = by_instance[name] or {}
700+
core.table.insert(by_instance[name], semantic.normalize(v))
701+
end
702+
end
703+
return by_instance
704+
end
705+
706+
707+
-- Guaranteed fallback: catchall instance if configured, else the first
708+
-- instance. Never fails, so a request always has a target.
709+
local function semantic_fallback(conf)
710+
for _, inst in ipairs(conf.instances) do
711+
if inst.catchall then
712+
return inst.name, inst
713+
end
714+
end
715+
local inst = conf.instances[1]
716+
return inst.name, inst
717+
end
718+
719+
720+
local function pick_semantic_instance(ctx, conf)
721+
local version = plugin.conf_version(conf)
722+
local ok, by_instance = pcall(lrucache_semantic_vectors,
723+
ctx.matched_route.key .. "#semantic", version,
724+
build_instance_vectors, conf)
725+
if not ok or not by_instance then
726+
core.log.warn("semantic routing: ", by_instance, ", falling back")
727+
return semantic_fallback(conf)
728+
end
729+
730+
local prompt = extract_last_user_message()
731+
if not prompt then
732+
core.log.warn("semantic routing: no user message found, falling back")
733+
return semantic_fallback(conf)
734+
end
735+
736+
local qvecs, err = embedding.fetch(conf.embeddings, { prompt })
737+
if not qvecs or not qvecs[1] then
738+
core.log.warn("semantic routing: query embedding failed: ", err, ", falling back")
739+
return semantic_fallback(conf)
740+
end
741+
local qvec = semantic.normalize(qvecs[1])
742+
local qdim = #qvec
743+
744+
local aggregation = conf.balancer.aggregation or "avg"
745+
local ranked = {}
746+
for _, inst in ipairs(conf.instances) do
747+
local refs = by_instance[inst.name]
748+
if refs then
749+
local scores = {}
750+
for _, rv in ipairs(refs) do
751+
-- guard against dimension drift (e.g. embedding model changed):
752+
-- mismatched vectors would make dot() error, so fail open instead.
753+
if #rv ~= qdim then
754+
core.log.warn("semantic routing: embedding dimension mismatch ",
755+
"(query ", qdim, " vs reference ", #rv, "), falling back")
756+
return semantic_fallback(conf)
757+
end
758+
scores[#scores + 1] = semantic.dot(qvec, rv)
759+
end
760+
core.table.insert(ranked, {
761+
name = inst.name,
762+
score = semantic.aggregate(scores, aggregation),
763+
})
764+
end
765+
end
766+
core.table.sort(ranked, function(a, b) return a.score > b.score end)
767+
768+
-- Highest score first; pick the first instance that clears its own threshold
769+
-- (per-instance override, else the global balancer.threshold).
770+
for _, cand in ipairs(ranked) do
771+
local inst = get_instance_conf(conf.instances, cand.name)
772+
local thr = inst.threshold or conf.balancer.threshold or 0
773+
if cand.score >= thr then
774+
core.log.info("semantic routing picked instance: ", cand.name,
775+
", score: ", cand.score)
776+
return cand.name, inst
777+
end
778+
end
779+
780+
-- Only on the fallback path: surface why nothing matched. Cheap, because
781+
-- this runs once per unmatched request rather than on every request.
782+
local parts = {}
783+
for _, c in ipairs(ranked) do
784+
parts[#parts + 1] = c.name .. ":" .. string.format("%.4f", c.score)
785+
end
786+
core.log.warn("semantic routing: no instance cleared threshold (scores: ",
787+
table_concat(parts, ","), "), falling back")
788+
return semantic_fallback(conf)
789+
end
790+
791+
601792
local function pick_ai_instance(ctx, conf, ups_tab)
602793
local instance_name, instance_conf, err
603-
if #conf.instances == 1 then
794+
if conf.balancer and conf.balancer.algorithm == "semantic" then
795+
instance_name, instance_conf = pick_semantic_instance(ctx, conf)
796+
elseif #conf.instances == 1 then
604797
instance_name = conf.instances[1].name
605798
instance_conf = conf.instances[1]
606799
else
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
--
2+
-- Licensed to the Apache Software Foundation (ASF) under one or more
3+
-- contributor license agreements. See the NOTICE file distributed with
4+
-- this work for additional information regarding copyright ownership.
5+
-- The ASF licenses this file to You under the Apache License, Version 2.0
6+
-- (the "License"); you may not use this file except in compliance with
7+
-- the License. You may obtain a copy of the License at
8+
--
9+
-- http://www.apache.org/licenses/LICENSE-2.0
10+
--
11+
-- Unless required by applicable law or agreed to in writing, software
12+
-- distributed under the License is distributed on an "AS IS" BASIS,
13+
-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
-- See the License for the specific language governing permissions and
15+
-- limitations under the License.
16+
--
17+
18+
-- Batch embedding client for the semantic balancer. Routes the request through
19+
-- the shared ai-provider layer (apisix.plugins.ai-providers.*) so endpoint
20+
-- resolution, authentication and HTTP transport match the chat path, instead of
21+
-- re-implementing them here. One call embeds a list of texts (OpenAI-compatible
22+
-- /embeddings: input is an array), returning a vector per text. Keeps semantic
23+
-- routing free of a vector database.
24+
local core = require("apisix.core")
25+
local ipairs = ipairs
26+
local type = type
27+
local tostring = tostring
28+
local require = require
29+
local pcall = pcall
30+
31+
local _M = {}
32+
33+
local EMBEDDINGS_PROTOCOL = "openai-embeddings"
34+
35+
36+
-- Resolve a provider's embeddings endpoint path from its capabilities.
37+
local function embeddings_path(ai_provider, provider_name)
38+
local cap = ai_provider.capabilities and ai_provider.capabilities[EMBEDDINGS_PROTOCOL]
39+
if not cap then
40+
return nil, "provider " .. tostring(provider_name) .. " does not support embeddings"
41+
end
42+
local path = cap.path
43+
if type(path) == "function" then
44+
path = path()
45+
end
46+
return path
47+
end
48+
49+
50+
-- Fetch embeddings for `texts` in a single batch request.
51+
-- Returns an array of vectors index-aligned with `texts`, or nil + err.
52+
function _M.fetch(conf, texts)
53+
if not texts or #texts == 0 then
54+
return {}
55+
end
56+
57+
if not conf.model or conf.model == "" then
58+
return nil, "embedding model is not configured"
59+
end
60+
61+
local ok, ai_provider = pcall(require, "apisix.plugins.ai-providers." .. conf.provider)
62+
if not ok then
63+
return nil, "failed to load ai-provider: " .. tostring(conf.provider)
64+
end
65+
66+
local target_path, perr = embeddings_path(ai_provider, conf.provider)
67+
if not target_path then
68+
return nil, perr
69+
end
70+
71+
-- The embedding call is a self-contained sidecar request; use a throwaway
72+
-- ctx so it never reads or mutates the in-flight request's ai_* fields.
73+
-- ai_request_body_changed = true tells build_request to send our
74+
-- { model, input } body instead of reusing the client's request body.
75+
local ctx = { ai_request_body_changed = true }
76+
local extra_opts = {
77+
endpoint = conf.endpoint,
78+
auth = conf.auth,
79+
target_path = target_path,
80+
-- This call carries its own credentials; never forward the client's
81+
-- headers (Authorization, Cookie, ...) to the embedding provider.
82+
skip_client_headers = true,
83+
}
84+
local req_conf = {
85+
ssl_verify = conf.ssl_verify ~= false,
86+
timeout = conf.timeout or 10000,
87+
keepalive = true,
88+
}
89+
90+
local status, raw_body, err = ai_provider:request(ctx, req_conf,
91+
{ model = conf.model, input = texts }, extra_opts)
92+
if status ~= 200 then
93+
if err then
94+
return nil, "embedding request failed: " .. err
95+
end
96+
-- Keep the upstream body out of the returned error: that error is logged
97+
-- once per failing request, and the body is untrusted third-party content
98+
-- (it can be large, carry sensitive data, or forge log lines with
99+
-- newlines). The status alone identifies the failure; the body is
100+
-- available at info level when debugging.
101+
core.log.info("embedding endpoint returned status ", status, ": ",
102+
raw_body or "")
103+
return nil, "embedding endpoint returned status " .. tostring(status)
104+
end
105+
106+
-- A 200 response whose body decodes to a scalar, boolean or null must not be
107+
-- indexed (`data.data` on a number raises), and an object without `data` must
108+
-- not have its body pasted into the error, which is logged per request.
109+
local data, derr = core.json.decode(raw_body)
110+
if type(data) ~= "table" or type(data.data) ~= "table" then
111+
core.log.info("invalid embedding response: ", raw_body or "")
112+
return nil, "invalid embedding response: " .. (derr or "unexpected shape")
113+
end
114+
115+
local vectors = {}
116+
for i, item in ipairs(data.data) do
117+
-- OpenAI returns an `index`; fall back to positional order.
118+
local idx = (type(item.index) == "number") and (item.index + 1) or i
119+
local emb = item.embedding
120+
if type(emb) ~= "table" or #emb == 0 then
121+
return nil, "invalid embedding vector at index " .. (idx - 1)
122+
end
123+
for _, n in ipairs(emb) do
124+
if type(n) ~= "number" then
125+
return nil, "non-numeric embedding component at index " .. (idx - 1)
126+
end
127+
end
128+
vectors[idx] = emb
129+
end
130+
131+
-- Every input position must have produced a vector; a hole means a
132+
-- dropped/duplicated index we must not silently route on. Checking each
133+
-- slot is reliable where `#vectors` is not on a sparse table.
134+
for i = 1, #texts do
135+
if vectors[i] == nil then
136+
return nil, "missing embedding for input index " .. (i - 1)
137+
end
138+
end
139+
return vectors
140+
end
141+
142+
143+
return _M

0 commit comments

Comments
 (0)