@@ -33,12 +33,16 @@ local ngx_now = ngx.now
3333
3434local require = require
3535local pcall = pcall
36+ local error = error
37+ local tostring = tostring
3638local ipairs = ipairs
3739local type = type
3840local string = string
3941local url = require (" socket.url" )
4042
4143local priority_balancer = require (" apisix.balancer.priority" )
44+ local semantic = require (" apisix.plugins.ai-proxy.semantic" )
45+ local embedding = require (" apisix.plugins.ai-proxy.embedding" )
4246local endpoint_regex = " ^(https?)://([^:/]+):?(%d*)/?.*$"
4347
4448local pickers = {}
@@ -48,6 +52,11 @@ local lrucache_server_picker = core.lrucache.new({
4852local 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
5261local plugin_name = " ai-proxy-multi"
5362local _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
160203end
161204
162205
@@ -598,9 +641,174 @@ local function pick_target(ctx, conf, ups_tab)
598641end
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+ local expose_scores = conf .balancer .expose_scores
769+ if expose_scores then
770+ local parts = {}
771+ for _ , c in ipairs (ranked ) do
772+ parts [# parts + 1 ] = c .name .. " :" .. string.format (" %.4f" , c .score )
773+ end
774+ core .response .set_header (" X-AI-Semantic-Scores" , table_concat (parts , " ," ))
775+ end
776+
777+ -- Highest score first; pick the first instance that clears its own threshold
778+ -- (per-instance override, else the global balancer.threshold).
779+ for _ , cand in ipairs (ranked ) do
780+ local inst = get_instance_conf (conf .instances , cand .name )
781+ local thr = inst .threshold or conf .balancer .threshold or 0
782+ if cand .score >= thr then
783+ if expose_scores then
784+ core .response .set_header (" X-AI-Semantic-Route" , cand .name )
785+ end
786+ core .log .info (" semantic routing picked instance: " , cand .name ,
787+ " , score: " , cand .score )
788+ return cand .name , inst
789+ end
790+ end
791+
792+ if expose_scores then
793+ core .response .set_header (" X-AI-Semantic-Route" , " fallback" )
794+ end
795+ -- Only on the fallback path: surface why nothing matched, without requiring
796+ -- expose_scores. Cheap, because this runs once per unmatched request.
797+ local unmatched = {}
798+ for _ , c in ipairs (ranked ) do
799+ unmatched [# unmatched + 1 ] = c .name .. " :" .. string.format (" %.4f" , c .score )
800+ end
801+ core .log .warn (" semantic routing: no instance cleared threshold (scores: " ,
802+ table_concat (unmatched , " ," ), " ), falling back" )
803+ return semantic_fallback (conf )
804+ end
805+
806+
601807local function pick_ai_instance (ctx , conf , ups_tab )
602808 local instance_name , instance_conf , err
603- if # conf .instances == 1 then
809+ if conf .balancer and conf .balancer .algorithm == " semantic" then
810+ instance_name , instance_conf = pick_semantic_instance (ctx , conf )
811+ elseif # conf .instances == 1 then
604812 instance_name = conf .instances [1 ].name
605813 instance_conf = conf .instances [1 ]
606814 else
0 commit comments