diff --git a/apisix/plugins/ai-providers/base.lua b/apisix/plugins/ai-providers/base.lua index a992defbaabc..6b8c8e35c9c1 100644 --- a/apisix/plugins/ai-providers/base.lua +++ b/apisix/plugins/ai-providers/base.lua @@ -188,7 +188,8 @@ function _M.build_request(self, ctx, conf, request_body, opts) .. "includes a path", 400 end - local headers = transport_http.construct_forward_headers(auth.header or {}, ctx) + local headers = transport_http.construct_forward_headers(auth.header or {}, ctx, + opts.skip_client_headers) if opts.host_header then headers["Host"] = opts.host_header end diff --git a/apisix/plugins/ai-proxy-multi.lua b/apisix/plugins/ai-proxy-multi.lua index 9b8f2eab5485..955cbb83007f 100644 --- a/apisix/plugins/ai-proxy-multi.lua +++ b/apisix/plugins/ai-proxy-multi.lua @@ -33,12 +33,18 @@ local ngx_now = ngx.now local require = require local pcall = pcall +local error = error +local tostring = tostring local ipairs = ipairs local type = type local string = string +local sub = string.sub local url = require("socket.url") local priority_balancer = require("apisix.balancer.priority") +local semantic = require("apisix.plugins.ai-proxy.semantic") +local embedding = require("apisix.plugins.ai-proxy.embedding") +local ai_protocols = require("apisix.plugins.ai-protocols") local endpoint_regex = "^(https?)://([^:/]+):?(%d*)/?.*$" local pickers = {} @@ -48,6 +54,16 @@ local lrucache_server_picker = core.lrucache.new({ local lrucache_health_status = core.lrucache.new({ ttl = 300, count = 256 }) +-- Keyed by route + conf version, so config changes invalidate immediately; +-- the long ttl just avoids re-embedding references on unchanged config. +local lrucache_semantic_vectors = core.lrucache.new({ + ttl = 3600, count = 256 +}) +-- The prompt is sent verbatim to a third-party embedding endpoint. Bound it: a +-- request body may be up to max_req_body_size (64MB by default), and an oversized +-- input would blow the embedding model's token limit, 400, and silently push every +-- large prompt to the fallback. Routing intent lives in the opening sentences. +local MAX_EMBED_PROMPT_BYTES = 8192 local plugin_name = "ai-proxy-multi" local _M = { @@ -156,7 +172,73 @@ function _M.check_schema(conf) end end - return ok + if algo == "semantic" then + local semantic_opts = conf.semantic_opts + if not semantic_opts or not semantic_opts.embeddings then + return false, "must configure `semantic_opts.embeddings` when balancer " .. + "algorithm is semantic" + end + local embeddings = semantic_opts.embeddings + -- Same scheme+host check the instance endpoints get: without it an + -- endpoint like "host/path" parses to a nil host and every request would + -- silently fail open, i.e. the route would never work with no signal. + local eendpoint = embeddings.endpoint + if eendpoint then + local scheme, host = eendpoint:match(endpoint_regex) + if not scheme or not host then + return false, "invalid `semantic_opts.embeddings.endpoint`" + end + end + if embeddings.provider == "azure-openai" then + -- Azure carries the deployment in the URL and declares no default host + -- or path, so the endpoint must be the full embeddings URL. Without a + -- path every request would silently fail open to the fallback. + if not eendpoint then + return false, "must configure `semantic_opts.embeddings.endpoint` " .. + "when embeddings provider is azure-openai" + end + local parsed = url.parse(eendpoint) + local epath = parsed and parsed.path + if not epath or epath == "" or epath == "/" then + return false, "`semantic_opts.embeddings.endpoint` for azure-openai " .. + "must include the full deployment path, e.g. " .. + "https://{resource}.openai.azure.com" .. + "/openai/deployments/{deployment}/embeddings?api-version=..." + end + end + -- The `fallback` instance, if named, is the only one exempt from the + -- examples requirement: it is reached by fallback, not by ranking. It must + -- name an instance that actually exists. + local fallback = semantic_opts.fallback + local fallback_found = false + for _, instance in ipairs(conf.instances) do + local is_fallback = fallback and instance.name == fallback + if is_fallback then + fallback_found = true + else + local has_example = false + if instance.examples then + for _, ex in ipairs(instance.examples) do + if type(ex) == "string" and ex ~= "" then + has_example = true + break + end + end + end + if not has_example then + return false, "instance '" .. (instance.name or "?") .. + "': must configure non-empty `examples` for the semantic " .. + "algorithm unless it is named by `semantic_opts.fallback`" + end + end + end + if fallback and not fallback_found then + return false, "`semantic_opts.fallback` names unknown instance '" .. + fallback .. "'" + end + end + + return true end @@ -598,9 +680,196 @@ local function pick_target(ctx, conf, ups_tab) end +local function extract_last_user_message(ctx) + local body = core.request.get_json_request_body_table() + if not body then + return nil + end + -- Normalize through the protocol adapter instead of reading body.messages + -- directly, so the prompt is found for every supported client protocol: + -- OpenAI Responses carries it in body.input, Anthropic/Bedrock in structured + -- content blocks. get_messages returns canonical {role, content} entries. + local messages = ai_protocols.get_messages(body, ctx) + for i = #messages, 1, -1 do + local m = messages[i] + if type(m) == "table" and m.role == "user" then + local content = m.content + if type(content) == "string" then + if content ~= "" then + return content + end + elseif type(content) == "table" then + -- Some adapters return multimodal content unflattened + -- ({type=text|image_url,...}); concatenate the text parts so + -- routing still works. + local parts = {} + for _, p in ipairs(content) do + if type(p) == "table" and p.type == "text" + and type(p.text) == "string" then + parts[#parts + 1] = p.text + end + end + if #parts > 0 then + return table_concat(parts, " ") + end + end + end + end + return nil +end + + +-- Embed every instance's examples in one batch and group the normalized +-- reference vectors by instance name. Raises on embedding failure so the +-- lrucache below does not cache a bad result. +local function build_instance_vectors(conf) + local texts = {} + local owners = {} + for _, inst in ipairs(conf.instances) do + if inst.examples then + for _, ex in ipairs(inst.examples) do + texts[#texts + 1] = ex + owners[#texts] = inst.name + end + end + end + + local vecs, err = embedding.fetch(conf.semantic_opts.embeddings, texts) + if not vecs then + error("failed to fetch reference embeddings: " .. tostring(err)) + end + + local by_instance = {} + for i, v in ipairs(vecs) do + local name = owners[i] + if name then + by_instance[name] = by_instance[name] or {} + core.table.insert(by_instance[name], semantic.normalize(v)) + end + end + return by_instance +end + + +-- Guaranteed fallback: the instance named by semantic_opts.fallback if +-- configured, else the first instance. Never fails, so a request always has a +-- target. +local function semantic_fallback(conf) + local fallback = conf.semantic_opts and conf.semantic_opts.fallback + if fallback then + for _, inst in ipairs(conf.instances) do + if inst.name == fallback then + return inst.name, inst + end + end + end + local inst = conf.instances[1] + return inst.name, inst +end + + +local function pick_semantic_instance(ctx, conf) + local version = plugin.conf_version(conf) + local ok, by_instance = pcall(lrucache_semantic_vectors, + ctx.matched_route.key .. "#semantic", version, + build_instance_vectors, conf) + if not ok or not by_instance then + core.log.warn("semantic routing: ", by_instance, ", falling back") + return semantic_fallback(conf) + end + + local prompt = extract_last_user_message(ctx) + if not prompt then + core.log.warn("semantic routing: no user message found, falling back") + return semantic_fallback(conf) + end + + if #prompt > MAX_EMBED_PROMPT_BYTES then + prompt = sub(prompt, 1, MAX_EMBED_PROMPT_BYTES) + end + + -- pcall, like the reference path above: the embedding response is + -- provider-controlled, so a raise here must fall back rather than 500. + local fetched, qvecs, err = pcall(embedding.fetch, conf.semantic_opts.embeddings, + { prompt }) + if not fetched then + core.log.warn("semantic routing: query embedding error: ", qvecs, ", falling back") + return semantic_fallback(conf) + end + if not qvecs or not qvecs[1] then + core.log.warn("semantic routing: query embedding failed: ", err, ", falling back") + return semantic_fallback(conf) + end + local qvec = semantic.normalize(qvecs[1]) + local qdim = #qvec + + local ranked = {} + for _, inst in ipairs(conf.instances) do + local refs = by_instance[inst.name] + if refs then + local scores = {} + for _, rv in ipairs(refs) do + -- guard against dimension drift (e.g. embedding model changed): + -- mismatched vectors would make dot() error, so fail open instead. + if #rv ~= qdim then + core.log.warn("semantic routing: embedding dimension mismatch ", + "(query ", qdim, " vs reference ", #rv, "), falling back") + return semantic_fallback(conf) + end + scores[#scores + 1] = semantic.dot(qvec, rv) + end + core.table.insert(ranked, { + name = inst.name, + score = semantic.max(scores), + }) + end + end + core.table.sort(ranked, function(a, b) return a.score > b.score end) + + local debugging = conf.semantic_opts.debugging + if debugging then + local parts = {} + for _, c in ipairs(ranked) do + parts[#parts + 1] = c.name .. ":" .. string.format("%.4f", c.score) + end + core.response.set_header("X-AI-Semantic-Scores", table_concat(parts, ",")) + end + + -- Highest score first; pick the first instance that clears its own threshold + -- (per-instance override, else the global semantic_opts.threshold). + for _, cand in ipairs(ranked) do + local inst = get_instance_conf(conf.instances, cand.name) + local thr = inst.threshold or conf.semantic_opts.threshold or 0 + if cand.score >= thr then + if debugging then + core.response.set_header("X-AI-Semantic-Route", cand.name) + end + core.log.info("semantic routing picked instance: ", cand.name, + ", score: ", cand.score) + return cand.name, inst + end + end + + if debugging then + core.response.set_header("X-AI-Semantic-Route", "fallback") + end + -- Only on the fallback path: surface why nothing matched, without requiring + -- debugging. Cheap, because this runs once per unmatched request. + local unmatched = {} + for _, c in ipairs(ranked) do + unmatched[#unmatched + 1] = c.name .. ":" .. string.format("%.4f", c.score) + end + core.log.warn("semantic routing: no instance cleared threshold (scores: ", + table_concat(unmatched, ","), "), falling back") + return semantic_fallback(conf) +end + + local function pick_ai_instance(ctx, conf, ups_tab) local instance_name, instance_conf, err - if #conf.instances == 1 then + if conf.balancer and conf.balancer.algorithm == "semantic" then + instance_name, instance_conf = pick_semantic_instance(ctx, conf) + elseif #conf.instances == 1 then instance_name = conf.instances[1].name instance_conf = conf.instances[1] else diff --git a/apisix/plugins/ai-proxy/embedding.lua b/apisix/plugins/ai-proxy/embedding.lua new file mode 100644 index 000000000000..ba93c8c696eb --- /dev/null +++ b/apisix/plugins/ai-proxy/embedding.lua @@ -0,0 +1,159 @@ +-- +-- 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. +-- + +-- Batch embedding client for the semantic balancer. Routes the request through +-- the shared ai-provider layer (apisix.plugins.ai-providers.*) so endpoint +-- resolution, authentication and HTTP transport match the chat path, instead of +-- re-implementing them here. One call embeds a list of texts (OpenAI-compatible +-- /embeddings: input is an array), returning a vector per text. Keeps semantic +-- routing free of a vector database. +local core = require("apisix.core") +local ipairs = ipairs +local type = type +local tostring = tostring +local require = require +local pcall = pcall +local INF = math.huge + +local _M = {} + +local EMBEDDINGS_PROTOCOL = "openai-embeddings" + + +-- The provider's embeddings capability supplies a default path when it has one +-- (openai: /v1/embeddings). Providers whose endpoint always carries its own path +-- (azure-openai) declare none, and build_request then requires `endpoint` to give +-- it. Which providers are allowed is gated by the `embeddings.provider` enum, so +-- a missing capability is not an error here. +local function embeddings_target_path(ai_provider) + local cap = ai_provider.capabilities and ai_provider.capabilities[EMBEDDINGS_PROTOCOL] + if not cap then + return nil + end + local path = cap.path + if type(path) == "function" then + path = path() + end + return path +end + + +-- Fetch embeddings for `texts` in a single batch request. +-- Returns an array of vectors index-aligned with `texts`, or nil + err. +function _M.fetch(conf, texts) + if not texts or #texts == 0 then + return {} + end + + if not conf.model or conf.model == "" then + return nil, "embedding model is not configured" + end + + local ok, ai_provider = pcall(require, "apisix.plugins.ai-providers." .. conf.provider) + if not ok then + return nil, "failed to load ai-provider: " .. tostring(conf.provider) + end + + local target_path = embeddings_target_path(ai_provider) + + -- The embedding call is a self-contained sidecar request; use a throwaway + -- ctx so it never reads or mutates the in-flight request's ai_* fields. + -- ai_request_body_changed = true tells build_request to send our + -- { model, input } body instead of reusing the client's request body. + local ctx = { ai_request_body_changed = true } + local extra_opts = { + endpoint = conf.endpoint, + auth = conf.auth, + target_path = target_path, + -- This call carries its own credentials; never forward the client's + -- headers (Authorization, Cookie, ...) to the embedding provider. + skip_client_headers = true, + } + local req_conf = { + ssl_verify = conf.ssl_verify ~= false, + timeout = conf.timeout or 3000, + keepalive = true, + } + + local status, raw_body, err = ai_provider:request(ctx, req_conf, + { model = conf.model, input = texts }, extra_opts) + -- The upstream body never reaches a log line or a returned error. It is + -- untrusted third-party content that may echo the prompt back, be arbitrarily + -- large, or forge log lines with newlines. Report the status and a bounded, + -- sanitized summary instead. + if status ~= 200 then + if err then + return nil, "embedding request failed: " .. err + end + return nil, "embedding endpoint returned status " .. tostring(status) + end + + -- A 200 body that decodes to a scalar, boolean or null must not be indexed + -- (`data.data` on a number raises). `derr` is cjson's own parse error, which + -- is bounded and carries no body content. + local data, derr = core.json.decode(raw_body) + if type(data) ~= "table" or type(data.data) ~= "table" then + return nil, "invalid embedding response: " .. (derr or "unexpected shape") + end + + local vectors = {} + for i, item in ipairs(data.data) do + -- `data` may be an array of non-objects; indexing those would raise and + -- turn a sidecar failure into a 500, so check before touching a field. + if type(item) ~= "table" then + return nil, "invalid embedding entry at index " .. (i - 1) + end + -- OpenAI returns an `index`; fall back to positional order. It must be a + -- usable table key: cjson decodes JSON `NaN` to a number, and `t[NaN]` + -- raises. A float or out-of-range index is equally unusable. + local idx = i + local raw_idx = item.index + if type(raw_idx) == "number" then + if raw_idx ~= raw_idx or raw_idx < 0 or raw_idx >= #texts + or raw_idx % 1 ~= 0 + then + return nil, "invalid embedding index at entry " .. (i - 1) + end + idx = raw_idx + 1 + end + local emb = item.embedding + if type(emb) ~= "table" or #emb == 0 then + return nil, "invalid embedding vector at index " .. (idx - 1) + end + for _, n in ipairs(emb) do + -- reject non-finite components: cjson decodes `1e999` to inf, which + -- would normalize to NaN and silently poison every similarity score. + if type(n) ~= "number" or n ~= n or n == INF or n == -INF then + return nil, "non-numeric embedding component at index " .. (idx - 1) + end + end + vectors[idx] = emb + end + + -- Every input position must have produced a vector; a hole means a + -- dropped/duplicated index we must not silently route on. Checking each + -- slot is reliable where `#vectors` is not on a sparse table. + for i = 1, #texts do + if vectors[i] == nil then + return nil, "missing embedding for input index " .. (i - 1) + end + end + return vectors +end + + +return _M diff --git a/apisix/plugins/ai-proxy/schema.lua b/apisix/plugins/ai-proxy/schema.lua index 5ffd6ef856fd..574a7d35a42e 100644 --- a/apisix/plugins/ai-proxy/schema.lua +++ b/apisix/plugins/ai-proxy/schema.lua @@ -202,12 +202,112 @@ local ai_instance_schema = { active = schema_def.health_checker_active, }, required = {"active"} - } + }, + examples = { + type = "array", + minItems = 1, + maxItems = 64, + items = { type = "string", minLength = 1 }, + description = "Example utterances representing this instance's " + .. "intent; each is embedded into its own reference vector " + .. "for the semantic algorithm. Required for every instance " + .. "except the one named by semantic_opts.fallback.", + }, + threshold = { + type = "number", + minimum = -1, + maximum = 1, + description = "Per-instance minimum cosine similarity for the " + .. "semantic algorithm; overrides semantic_opts.threshold.", + }, }, required = {"name", "provider", "auth", "weight"}, }, } +local embeddings_schema = { + type = "object", + properties = { + provider = { + type = "string", + enum = { "openai", "azure-openai" }, + description = "Embedding provider used by the semantic algorithm.", + }, + model = { + type = "string", + minLength = 1, + description = "Embedding model name, e.g. text-embedding-3-small.", + }, + endpoint = { + type = "string", + description = "Embedding API endpoint. Optional for openai (defaults " + .. "to the public API). Required for azure-openai, where it must " + .. "be the full URL, e.g. https://{resource}.openai.azure.com" + .. "/openai/deployments/{deployment}/embeddings?api-version=...", + }, + auth = { + type = "object", + properties = { + header = { type = "object", additionalProperties = { type = "string" } }, + query = { type = "object", additionalProperties = { type = "string" } }, + }, + -- header/query only. The sidecar call runs on a throwaway ctx, so the + -- ctx-dependent schemes (gcp, aws) cannot work here; reject them at + -- config time instead of failing open on every request. + additionalProperties = false, + }, + timeout = { + type = "integer", + minimum = 1, + default = 3000, + description = "Embedding request timeout in milliseconds. The query " + .. "prompt is embedded synchronously on every request, so this " + .. "bounds the latency added to each request when the embedding " + .. "endpoint is slow or down (the request then fails open to the " + .. "fallback).", + }, + ssl_verify = { type = "boolean", default = true }, + }, + required = { "provider", "model", "auth" }, +} + +-- All options specific to the semantic balancer live here, so they are grouped +-- in one place instead of being scattered across balancer/instances/top-level. +local semantic_opts_schema = { + type = "object", + properties = { + embeddings = embeddings_schema, + threshold = { + type = "number", + minimum = -1, + maximum = 1, + default = 0, + description = "Global minimum cosine similarity for the semantic " + .. "algorithm. When no instance clears its threshold the request " + .. "falls back to the `fallback` instance, or the first instance " + .. "if none is named. The default of 0 admits essentially any " + .. "prompt, so the fallback only ever runs if a threshold above 0 " + .. "is set.", + }, + fallback = { + type = "string", + minLength = 1, + description = "Name of the instance to route to when no instance " + .. "clears its threshold or the embedding request fails. Unlike a " + .. "ranked instance it needs no `examples`. Defaults to the first " + .. "instance when unset.", + }, + debugging = { + type = "boolean", + default = false, + description = "When true, the semantic algorithm exposes per-instance " + .. "scores and the routing decision via X-AI-Semantic-* response " + .. "headers, for debugging.", + }, + }, + required = { "embeddings" }, +} + local logging_schema = { type = "object", properties = { @@ -303,7 +403,15 @@ _M.ai_proxy_multi_schema = { properties = { algorithm = { type = "string", - enum = { "chash", "roundrobin" }, + enum = { "chash", "roundrobin", "semantic" }, + description = "Load-balancing algorithm. Note: 'semantic' " + .. "picks an instance by prompt similarity and does not " + .. "participate in health checks or fallback_strategy / " + .. "retry — an upstream failure on the chosen instance is " + .. "returned to the client. It only falls back (to the " + .. "semantic_opts.fallback, else the first instance) when " + .. "no instance clears its threshold or embedding fails. " + .. "Configure it under `semantic_opts`.", }, hash_on = { type = "string", @@ -324,6 +432,7 @@ _M.ai_proxy_multi_schema = { default = { algorithm = "roundrobin" } }, instances = ai_instance_schema, + semantic_opts = semantic_opts_schema, logging = logging_schema, fallback_strategy = { anyOf = { @@ -419,6 +528,8 @@ _M.ai_proxy_multi_schema = { "instances.auth.gcp.service_account_json", "instances.auth.aws.secret_access_key", "instances.auth.aws.session_token", + "semantic_opts.embeddings.auth.header", + "semantic_opts.embeddings.auth.query", }, } diff --git a/apisix/plugins/ai-proxy/semantic.lua b/apisix/plugins/ai-proxy/semantic.lua new file mode 100644 index 000000000000..c58c8629dc5f --- /dev/null +++ b/apisix/plugins/ai-proxy/semantic.lua @@ -0,0 +1,83 @@ +-- +-- 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. +-- + +-- Pure-lua vector math for semantic routing: no apisix.core dependency so it can +-- be unit-tested standalone. Vectors are plain arrays of numbers. +local sqrt = math.sqrt + +local _M = {} + + +-- Return the unit vector of `vec`. A zero vector normalizes to zeros (no NaN). +function _M.normalize(vec) + local sum = 0 + for i = 1, #vec do + sum = sum + vec[i] * vec[i] + end + local norm = sqrt(sum) + local out = {} + if norm == 0 then + for i = 1, #vec do + out[i] = 0 + end + return out + end + for i = 1, #vec do + out[i] = vec[i] / norm + end + return out +end + + +-- Dot product. On pre-normalized vectors this equals cosine similarity. +function _M.dot(a, b) + local sum = 0 + for i = 1, #a do + sum = sum + a[i] * b[i] + end + return sum +end + + +-- Cosine similarity in [-1, 1]. Normalizes both inputs; for hot paths prefer +-- pre-normalizing once and calling dot() directly. +function _M.cosine(a, b) + return _M.dot(_M.normalize(a), _M.normalize(b)) +end + + +-- Score an instance from its per-example similarities. +-- `examples` are alternative phrasings of one intent, so the question is whether +-- ANY of them matches: take the best. Averaging would dilute a dead-on example +-- with the instance's broader ones, and would make adding an example lower the +-- instance's score. +function _M.max(scores) + local n = #scores + if n == 0 then + return nil + end + local m = scores[1] + for i = 2, n do + if scores[i] > m then + m = scores[i] + end + end + return m +end + + +return _M diff --git a/apisix/plugins/ai-transport/http.lua b/apisix/plugins/ai-transport/http.lua index 5ea9d2194545..ee142061a32b 100644 --- a/apisix/plugins/ai-transport/http.lua +++ b/apisix/plugins/ai-transport/http.lua @@ -45,7 +45,11 @@ end --- Build forwarded headers from client request + extra headers. -- Copies client headers, merges ext_opts_headers (lowercased), -- forces Content-Type to application/json, removes host/content-length. -function _M.construct_forward_headers(ext_opts_headers, ctx) +-- When skip_client_headers is true the client's request headers are not +-- forwarded. Use it for self-contained sidecar calls (e.g. embeddings), which +-- carry their own credentials and must not leak the client's Authorization, +-- Cookie or other headers to a third-party endpoint. +function _M.construct_forward_headers(ext_opts_headers, ctx, skip_client_headers) local blacklist = { "host", "content-length", @@ -53,8 +57,10 @@ function _M.construct_forward_headers(ext_opts_headers, ctx) } local headers = {} - for k, v in pairs(core.request.headers(ctx) or {}) do - headers[str_lower(k)] = v + if not skip_client_headers then + for k, v in pairs(core.request.headers(ctx) or {}) do + headers[str_lower(k)] = v + end end for k, v in pairs(ext_opts_headers or {}) do headers[str_lower(k)] = v diff --git a/docs/en/latest/plugins/ai-proxy-multi.md b/docs/en/latest/plugins/ai-proxy-multi.md index a91b0f43b160..9f0523303eda 100644 --- a/docs/en/latest/plugins/ai-proxy-multi.md +++ b/docs/en/latest/plugins/ai-proxy-multi.md @@ -71,7 +71,7 @@ When an instance's `provider` is set to `bedrock`, the Plugin expects requests i | max_retries | integer | False | | greater or equal to 0 | Maximum number of fallback retries after the initial request fails. Bounds how many additional instances a single request tries, so it does not exhaust every configured instance. Only takes effect together with `fallback_strategy`. When unset, the Plugin retries until an instance succeeds or all are tried. | | retry_on_failure_within_ms | integer | False | | greater or equal to 1 | Only fall back to another instance when the upstream fails within this many milliseconds. Fast failures (such as connection errors or quick `429`/`5xx`) are retried, while a slow failure that takes longer than this is returned to the client directly to avoid doubling the wait time. Only takes effect together with `fallback_strategy`. When unset, the Plugin retries regardless of how long the failed attempt took. | | balancer | object | False | | | Load balancing configurations. | -| balancer.algorithm | string | False | roundrobin | [roundrobin, chash] | Load balancing algorithm. When set to `roundrobin`, weighted round robin algorithm is used. When set to `chash`, consistent hashing algorithm is used. | +| balancer.algorithm | string | False | roundrobin | [roundrobin, chash, semantic] | Load balancing algorithm. When set to `roundrobin`, weighted round robin algorithm is used. When set to `chash`, consistent hashing algorithm is used. When set to `semantic`, the Plugin picks an instance by the semantic similarity between the request prompt and each instance's `examples`, and its options are configured under `semantic_opts`. Note that `semantic` does not participate in health checks or `fallback_strategy` / retry — an upstream failure on the chosen instance is returned to the client; it only falls back (to the `semantic_opts.fallback` instance, else the first instance) when no instance clears its threshold or embedding fails. | | balancer.hash_on | string | False | | [vars, headers, cookie, consumer, vars_combinations] | Used when `type` is `chash`. Support hashing on [NGINX variables](https://nginx.org/en/docs/varindex.html), headers, cookie, consumer, or a combination of [NGINX variables](https://nginx.org/en/docs/varindex.html). | | balancer.key | string | False | | | Used when `type` is `chash`. When `hash_on` is set to `header` or `cookie`, `key` is required. When `hash_on` is set to `consumer`, `key` is not required as the consumer name will be used as the key automatically. | | instances | array[object] | True | | | LLM instance configurations. | @@ -95,6 +95,8 @@ When an instance's `provider` is set to `bedrock`, the Plugin expects requests i | instances.auth.aws.session_token | string | False | | minLength = 1 | AWS session token for temporary credentials (e.g., from STS assume-role). Encrypted at rest. | | instances.options | object | False | | | Model configurations. In addition to `model`, you can configure additional parameters and they will be forwarded to the upstream LLM service in the request body. For instance, if you are working with OpenAI, DeepSeek, or AIMLAPI, you can configure additional parameters such as `max_tokens`, `temperature`, `top_p`, and `stream`. See your LLM provider's API documentation for more available options. | | instances.options.model | string | False | | | Name of the LLM model, such as `gpt-4` or `gpt-3.5`. See your LLM provider's API documentation for more available models. For Bedrock, this can be a foundation model ID (e.g., `anthropic.claude-3-5-sonnet-20240620-v1:0`), a cross-region inference profile ID (e.g., `us.anthropic.claude-3-5-sonnet-20240620-v1:0`), or an application inference profile ARN (e.g., `arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/abc123`). | +| instances.examples | array[string] | False | | 1 to 64 items | Used when `algorithm` is `semantic`. Example utterances representing this instance's intent; each is embedded into its own reference vector. Required for every instance except the one named by `semantic_opts.fallback`. | +| instances.threshold | number | False | | -1 to 1 | Used when `algorithm` is `semantic`. Per-instance minimum cosine similarity; overrides `semantic_opts.threshold`. | | logging | object | False | | | Logging configurations. | | logging.summaries | boolean | False | false | | If true, log request LLM model, duration, request, and response tokens. | | logging.payloads | boolean | False | false | | If true, log request and response payload. | @@ -122,6 +124,19 @@ When an instance's `provider` is set to `bedrock`, the Plugin expects requests i | instances.checks.active.unhealthy.http_statuses | array[integer] | False | [429,404,500,501,502,503,504,505] | status code between 200 and 599 inclusive | An array of HTTP status codes that defines an unhealthy node. | | instances.checks.active.unhealthy.http_failures | integer | False | 5 | between 1 and 254 inclusive | Number of HTTP failures to define an unhealthy node. | | instances.checks.active.unhealthy.timeout | integer | False | 3 | between 1 and 254 inclusive | Number of probe timeouts to define an unhealthy node. | +| semantic_opts | object | False | | | Options for the `semantic` balancer, grouped in one place. Required when `balancer.algorithm` is `semantic`. | +| semantic_opts.threshold | number | False | 0 | -1 to 1 | Global minimum cosine similarity an instance must reach to be selected. **The default of 0 admits essentially any prompt** (real embeddings are rarely dissimilar enough to score below 0), so the fallback only ever runs if you raise this above 0. When no instance clears its threshold, the request falls back to the `fallback` instance, or the first instance if none is named. | +| semantic_opts.fallback | string | False | | | Name of the instance to route to when no instance clears its threshold or the embedding request fails. Unlike a ranked instance it needs no `examples`. Defaults to the first instance when unset. | +| semantic_opts.debugging | boolean | False | false | | If true, expose per-instance scores and the routing decision via `X-AI-Semantic-Scores` and `X-AI-Semantic-Route` response headers, for debugging. | +| semantic_opts.embeddings | object | True | | | Embedding service configuration used to score prompts against each instance's `examples`. | +| semantic_opts.embeddings.provider | string | True | | [openai, azure-openai] | Embedding provider used by the semantic algorithm. | +| semantic_opts.embeddings.model | string | True | | | Embedding model name, e.g. `text-embedding-3-small`. | +| semantic_opts.embeddings.endpoint | string | False | | | Embedding API endpoint. Optional for `openai` (defaults to the public API). Required for `azure-openai`, where it must be the **full** URL including the deployment path and API version, e.g. `https://{resource}.openai.azure.com/openai/deployments/{deployment}/embeddings?api-version=2024-02-01`. | +| semantic_opts.embeddings.auth | object | True | | | Authentication configurations for the embedding service. | +| semantic_opts.embeddings.auth.header | object | False | | | Authentication headers. Encrypted at rest. | +| semantic_opts.embeddings.auth.query | object | False | | | Authentication query parameters. Encrypted at rest. | +| semantic_opts.embeddings.timeout | integer | False | 3000 | minimum = 1 | Embedding request timeout in milliseconds. The query prompt is embedded synchronously on every request, so this bounds the latency added to each request when the embedding endpoint is slow or down (the request then fails open to the fallback). | +| semantic_opts.embeddings.ssl_verify | boolean | False | true | | If true, verify the embedding service's certificate. | | timeout | integer | False | 30000 | greater than or equal to 1 | Request timeout in milliseconds when requesting the LLM service. Applied per socket operation (connect / send / read block); does not cap the total duration of a streaming response. | | max_req_body_size | integer | False | 67108864 | greater than or equal to 1 | Maximum request body size in bytes that the plugin reads into memory. Requests whose body exceeds this limit are rejected with `413`. Prevents unbounded memory buffering of large request bodies. | | max_stream_duration_ms | integer | False | | greater than or equal to 1 | Maximum wall-clock duration (in milliseconds) for a streaming AI response. If the upstream keeps sending data past this deadline, the gateway closes the connection. Unset means no cap. Use this to protect the gateway from upstream bugs that produce tokens indefinitely. When the limit is hit mid-stream, the downstream SSE stream is truncated (no protocol-specific terminator such as `[DONE]`, `message_stop`, or `response.completed`); well-behaved clients should treat a missing terminator as an incomplete response. | @@ -2974,3 +2989,139 @@ You should receive a response similar to the following if the request is forward ``` In the Kafka topic, you should also see a log entry corresponding to the request with the LLM summary and request/response payload. + +### Route by Semantic Similarity + +The following example demonstrates how you can use the `semantic` algorithm to pick an instance by the meaning of the request prompt, rather than by weight or hash. Each ranked instance declares `examples` that describe the kind of prompt it should handle; the Plugin embeds those examples and the incoming prompt with the configured embedding model, and forwards the request to the instance whose examples are most similar. + +Create a Route as such and update the LLM providers, embedding model, and API keys accordingly. The `code` instance handles programming prompts, the `translate` instance handles translation prompts, and the `default` instance is named as the `semantic_opts.fallback`, used when no instance clears the similarity threshold: + +```shell +curl "http://127.0.0.1:9180/apisix/admin/routes" -X PUT \ + -H "X-API-KEY: ${admin_key}" \ + -d '{ + "id": "ai-proxy-multi-route", + "uri": "/anything", + "methods": ["POST"], + "plugins": { + "ai-proxy-multi": { + "balancer": { + "algorithm": "semantic" + }, + "semantic_opts": { + "embeddings": { + "provider": "openai", + "model": "text-embedding-3-small", + "auth": { + "header": { + "Authorization": "Bearer '"$YOUR_EMBEDDING_API_KEY"'" + } + } + }, + "threshold": 0.5, + "fallback": "default" + }, + "instances": [ + { + "name": "code", + "provider": "openai", + "weight": 1, + "auth": { + "header": { + "Authorization": "Bearer '"$YOUR_LLM_API_KEY"'" + } + }, + "options": { + "model": "gpt-4o" + }, + "examples": [ + "write a python function", + "debug this code" + ] + }, + { + "name": "translate", + "provider": "openai", + "weight": 1, + "auth": { + "header": { + "Authorization": "Bearer '"$YOUR_LLM_API_KEY"'" + } + }, + "options": { + "model": "gpt-4o-mini" + }, + "examples": [ + "translate this sentence into English" + ] + }, + { + "name": "default", + "provider": "openai", + "weight": 1, + "auth": { + "header": { + "Authorization": "Bearer '"$YOUR_LLM_API_KEY"'" + } + }, + "options": { + "model": "gpt-4o-mini" + } + } + ] + } + } + }' +``` + +Send a request with a programming prompt: + +```shell +curl "http://127.0.0.1:9080/anything" -X POST \ + -H "Content-Type: application/json" \ + -d '{ + "model": "auto", + "messages": [ + { "role": "user", "content": "help me debug this python code" } + ] + }' +``` + +The request should be routed to the `code` instance and served by `gpt-4o`. A prompt unrelated to any instance's `examples` (for example, `what is the weather today`) clears no threshold and is routed to the `default` fallback instance instead. + +#### Debugging the routing decision + +Set `semantic_opts.debugging` to `true` to have the Plugin report how each instance scored and which one it picked: + +```shell +curl -i "http://127.0.0.1:9080/anything" -X POST \ + -H "Content-Type: application/json" \ + -d '{"model":"auto","messages":[{"role":"user","content":"help me debug this python code"}]}' +``` + +```text +HTTP/1.1 200 OK +X-AI-Semantic-Route: code +X-AI-Semantic-Scores: code:0.8213,translate:0.1904 +``` + +`X-AI-Semantic-Scores` lists every instance that has `examples`, highest score first, so you can see not only the winner but how close the runners-up came. Instances without `examples` — such as a pure fallback — carry no similarity score and therefore do not appear. + +When no instance clears its threshold, the pick becomes `fallback` while the scores still show how close each one got: + +```text +X-AI-Semantic-Route: fallback +X-AI-Semantic-Scores: code:0.3057,translate:0.2884 +``` + +Use this to calibrate `threshold`: pick a value between the scores of the prompts you want matched and the scores of the prompts you want to reach the fallback. + +The same information is available without exposing it to clients: whenever no instance clears its threshold, the Plugin logs the full score list at `warn` level. + +```text +semantic routing: no instance cleared threshold (scores: code:0.3057,translate:0.2884), falling back +``` + +If the headers are missing entirely while `debugging` is on, the embedding step itself failed and the request was served by the fallback before any score was computed; the reason is logged at `warn`. + +Because `debugging` reveals instance names to the caller, keep it off in production and rely on the log line instead. diff --git a/docs/zh/latest/plugins/ai-proxy-multi.md b/docs/zh/latest/plugins/ai-proxy-multi.md index 8f3ea99dc615..4b0bfd5214c5 100644 --- a/docs/zh/latest/plugins/ai-proxy-multi.md +++ b/docs/zh/latest/plugins/ai-proxy-multi.md @@ -71,7 +71,7 @@ import TabItem from '@theme/TabItem'; | max_retries | integer | 否 | | 大于或等于 0 | 初始请求失败后允许的最大故障转移重试次数。用于限制单个请求最多尝试多少个额外实例,避免穷举所有已配置的实例。仅在配置 `fallback_strategy` 时生效。未设置时,插件会持续重试直到某个实例成功或所有实例都已尝试。 | | retry_on_failure_within_ms | integer | 否 | | 大于或等于 1 | 仅当上游在指定毫秒数内失败时才故障转移到其他实例。快速失败(如连接错误、快速返回的 `429`/`5xx`)会触发重试,而耗时超过该值的慢失败会直接将错误返回给客户端,避免客户端等待时间翻倍。仅在配置 `fallback_strategy` 时生效。未设置时,插件无论失败请求耗时多久都会重试。 | | balancer | object | 否 | | | 负载均衡配置。 | -| balancer.algorithm | string | 否 | roundrobin | [roundrobin, chash] | 负载均衡算法。设置为 `roundrobin` 时,使用加权轮询算法。设置为 `chash` 时,使用一致性哈希算法。 | +| balancer.algorithm | string | 否 | roundrobin | [roundrobin, chash, semantic] | 负载均衡算法。设置为 `roundrobin` 时,使用加权轮询算法。设置为 `chash` 时,使用一致性哈希算法。设置为 `semantic` 时,插件根据请求提示词与各实例 `examples` 之间的语义相似度选择实例,其相关选项配置在 `semantic_opts` 下。注意:`semantic` 不参与健康检查,也不参与 `fallback_strategy` / 重试——被选中实例的上游失败会直接返回给客户端;只有当没有实例达到其阈值或嵌入请求失败时,才会回退(回退到 `semantic_opts.fallback` 实例,若未配置则回退到第一个实例)。 | | balancer.hash_on | string | 否 | | [vars, headers, cookie, consumer, vars_combinations] | 当 `type` 为 `chash` 时使用。支持基于 [NGINX 变量](https://nginx.org/en/docs/varindex.html)、标头、cookie、消费者或 [NGINX 变量](https://nginx.org/en/docs/varindex.html)组合进行哈希。 | | balancer.key | string | 否 | | | 当 `type` 为 `chash` 时使用。当 `hash_on` 设置为 `header` 或 `cookie` 时,需要 `key`。当 `hash_on` 设置为 `consumer` 时,不需要 `key`,因为消费者名称将自动用作键。 | | instances | array[object] | 是 | | | LLM 实例配置。 | @@ -101,6 +101,8 @@ import TabItem from '@theme/TabItem'; | instances.override.llm_options.max_tokens | integer | 否 | | ≥ 1 | 最大输出 token 数。APISIX 会自动将该值映射为各上游服务商对应的字段名。始终强制覆盖客户端值。 | | instances.override.request_body | object | 否 | | | 按目标协议的请求体覆盖配置。请参阅 `ai-proxy` 文档中的[按协议的请求体覆盖](./ai-proxy.md#per-protocol-request-body-override)。 | | instances.override.request_body_force_override | boolean | 否 | false | | 为 `false`(默认)时,客户端请求体中的字段优先,`instances.override.request_body` 仅补充缺失字段。为 `true` 时,`instances.override.request_body` 的值强制覆盖客户端请求体中的同名字段。不影响 `instances.override.llm_options`。 | +| instances.examples | array[string] | 否 | | 1 到 64 项 | 当 `algorithm` 为 `semantic` 时使用。代表该实例意图的示例语句;每一条都会被嵌入为独立的参考向量。除被 `semantic_opts.fallback` 指定的实例外均为必填。 | +| instances.threshold | number | 否 | | -1 到 1 | 当 `algorithm` 为 `semantic` 时使用。该实例的最小余弦相似度,覆盖 `semantic_opts.threshold`。 | | logging | object | 否 | | | 日志配置。不影响 `error.log`。 | | logging.summaries | boolean | 否 | false | | 如果为 true,记录请求 LLM 模型、持续时间、请求和响应令牌。 | | logging.payloads | boolean | 否 | false | | 如果为 true,记录请求和响应负载。 | @@ -124,6 +126,19 @@ import TabItem from '@theme/TabItem'; | instances.checks.active.unhealthy.http_statuses | array[integer] | 否 | [429,404,500,501,502,503,504,505] | 200 到 599 之间的状态码(包含) | 定义不健康节点的 HTTP 状态码数组。 | | instances.checks.active.unhealthy.http_failures | integer | 否 | 5 | 1 到 254(包含) | 定义不健康节点的 HTTP 失败次数。 | | instances.checks.active.unhealthy.timeout | integer | 否 | 3 | 1 到 254(包含) | 定义不健康节点的探测超时次数。 | +| semantic_opts | object | 否 | | | `semantic` 均衡器的选项,集中配置在一处。当 `balancer.algorithm` 为 `semantic` 时必填。 | +| semantic_opts.threshold | number | 否 | 0 | -1 到 1 | 实例被选中所需达到的全局最小余弦相似度。**默认值 0 几乎会接纳任何提示词**(真实嵌入向量很少会不相似到得分低于 0),因此只有把它调到 0 以上,回退实例才会真正生效。当没有实例达到其阈值时,请求回退到 `fallback` 实例;若未指定,则回退到第一个实例。 | +| semantic_opts.fallback | string | 否 | | | 当没有实例达到其阈值或嵌入请求失败时,路由到的实例名称。与参与排名的实例不同,它无需配置 `examples`。未设置时默认回退到第一个实例。 | +| semantic_opts.debugging | boolean | 否 | false | | 若为 true,通过 `X-AI-Semantic-Scores` 和 `X-AI-Semantic-Route` 响应头暴露各实例的分数和路由决策,用于调试。 | +| semantic_opts.embeddings | object | 是 | | | 嵌入服务配置,用于将提示词与各实例的 `examples` 进行相似度打分。 | +| semantic_opts.embeddings.provider | string | 是 | | [openai, azure-openai] | 语义算法使用的嵌入服务提供商。 | +| semantic_opts.embeddings.model | string | 是 | | | 嵌入模型名称,例如 `text-embedding-3-small`。 | +| semantic_opts.embeddings.endpoint | string | 否 | | | 嵌入 API 端点。对于 `openai` 可选(默认使用公共 API)。对于 `azure-openai` 必填,且必须是**完整** URL,包含 deployment 路径和 API 版本,例如 `https://{resource}.openai.azure.com/openai/deployments/{deployment}/embeddings?api-version=2024-02-01`。 | +| semantic_opts.embeddings.auth | object | 是 | | | 嵌入服务的认证配置。 | +| semantic_opts.embeddings.auth.header | object | 否 | | | 认证请求头。加密存储。 | +| semantic_opts.embeddings.auth.query | object | 否 | | | 认证查询参数。加密存储。 | +| semantic_opts.embeddings.timeout | integer | 否 | 3000 | 最小值为 1 | 嵌入请求的超时时间(毫秒)。每个请求都会同步嵌入查询提示词,因此该值限定了当嵌入端点变慢或不可用时每个请求增加的延迟上限(随后请求会 fail-open 到回退实例)。 | +| semantic_opts.embeddings.ssl_verify | boolean | 否 | true | | 如果为 true,验证嵌入服务的证书。 | | timeout | integer | 否 | 30000 | 大于或等于 1 | 请求 LLM 服务时的请求超时时间(毫秒)。应用于单次 socket 操作(连接 / 发送 / 读取块),不限制流式响应的总时长。 | | max_stream_duration_ms | integer | 否 | | 大于或等于 1 | 流式 AI 响应的总墙钟时长上限(毫秒)。若上游在此时间后仍持续发送数据,网关将关闭连接。未设置时不限制。用于防护上游持续输出 token 导致网关 CPU 被打满的异常情况。中途触发上限时,下游 SSE 流会被截断(不再发送协议特定的终止标记,例如 `[DONE]`、`message_stop` 或 `response.completed`),客户端应将缺失的终止标记视为响应未完成。 | | max_response_bytes | integer | 否 | | 大于或等于 1 | 单次 AI 响应(流式或非流式)允许从上游读取的最大总字节数。超出时关闭连接。非流式响应若存在 `Content-Length`,在读取 body 之前预检;否则(chunked 传输)与流式响应一样在接收字节的过程中增量检查。未设置时不限制。 | @@ -2779,3 +2794,139 @@ nginx_config: ``` 访问日志条目显示请求类型为 `ai_chat`,Apisix 上游响应时间为 `5765` 毫秒,首次令牌时间为 `2858` 毫秒,请求的 LLM 模型为 `gpt-4`。LLM 模型为 `gpt-4`,提示令牌使用量为 `23`,完成令牌使用量为 `8`。 + +### 按语义相似度路由 + +以下示例演示了如何使用 `semantic` 算法,根据请求提示词的语义而非权重或哈希来选择实例。每个参与排名的实例通过 `examples` 声明它应当处理的提示词类型;插件使用配置的嵌入模型将这些示例和传入的提示词嵌入为向量,并将请求转发给示例与之最相似的实例。 + +创建如下路由,并相应地更新 LLM 提供商、嵌入模型和 API 密钥。`code` 实例处理编程类提示词,`translate` 实例处理翻译类提示词,`default` 实例被指定为 `semantic_opts.fallback`,当没有实例达到相似度阈值时使用: + +```shell +curl "http://127.0.0.1:9180/apisix/admin/routes" -X PUT \ + -H "X-API-KEY: ${admin_key}" \ + -d '{ + "id": "ai-proxy-multi-route", + "uri": "/anything", + "methods": ["POST"], + "plugins": { + "ai-proxy-multi": { + "balancer": { + "algorithm": "semantic" + }, + "semantic_opts": { + "embeddings": { + "provider": "openai", + "model": "text-embedding-3-small", + "auth": { + "header": { + "Authorization": "Bearer '"$YOUR_EMBEDDING_API_KEY"'" + } + } + }, + "threshold": 0.5, + "fallback": "default" + }, + "instances": [ + { + "name": "code", + "provider": "openai", + "weight": 1, + "auth": { + "header": { + "Authorization": "Bearer '"$YOUR_LLM_API_KEY"'" + } + }, + "options": { + "model": "gpt-4o" + }, + "examples": [ + "write a python function", + "debug this code" + ] + }, + { + "name": "translate", + "provider": "openai", + "weight": 1, + "auth": { + "header": { + "Authorization": "Bearer '"$YOUR_LLM_API_KEY"'" + } + }, + "options": { + "model": "gpt-4o-mini" + }, + "examples": [ + "translate this sentence into English" + ] + }, + { + "name": "default", + "provider": "openai", + "weight": 1, + "auth": { + "header": { + "Authorization": "Bearer '"$YOUR_LLM_API_KEY"'" + } + }, + "options": { + "model": "gpt-4o-mini" + } + } + ] + } + } + }' +``` + +发送一个编程类提示词的请求: + +```shell +curl "http://127.0.0.1:9080/anything" -X POST \ + -H "Content-Type: application/json" \ + -d '{ + "model": "auto", + "messages": [ + { "role": "user", "content": "help me debug this python code" } + ] + }' +``` + +该请求应被路由到 `code` 实例,由 `gpt-4o` 处理。如果提示词与任何实例的 `examples` 都不相关(例如 `what is the weather today`),则不会达到任何阈值,请求会被路由到 `default` 这个回退实例。 + +#### 调试路由决策 + +将 `semantic_opts.debugging` 设置为 `true`,插件会在响应中报告各实例的得分以及最终选中的实例: + +```shell +curl -i "http://127.0.0.1:9080/anything" -X POST \ + -H "Content-Type: application/json" \ + -d '{"model":"auto","messages":[{"role":"user","content":"help me debug this python code"}]}' +``` + +```text +HTTP/1.1 200 OK +X-AI-Semantic-Route: code +X-AI-Semantic-Scores: code:0.8213,translate:0.1904 +``` + +`X-AI-Semantic-Scores` 按得分从高到低列出**所有配置了 `examples` 的实例**,因此你不仅能看到胜出者,也能看到其他实例差了多少。没有配置 `examples` 的实例(例如纯回退实例)没有相似度得分,因此不会出现在其中。 + +当没有任何实例达到阈值时,选中结果变为 `fallback`,而得分仍会显示各实例的接近程度: + +```text +X-AI-Semantic-Route: fallback +X-AI-Semantic-Scores: code:0.3057,translate:0.2884 +``` + +据此可以校准 `threshold`:取一个介于「希望匹配的提示词得分」与「希望落到回退实例的提示词得分」之间的值。 + +同样的信息也可以不暴露给客户端:每当没有实例达到阈值时,插件都会以 `warn` 级别记录完整的得分列表。 + +```text +semantic routing: no instance cleared threshold (scores: code:0.3057,translate:0.2884), falling back +``` + +如果开启了 `debugging` 却完全没有这两个响应头,说明嵌入请求本身失败了,请求在计算任何得分之前就已回退到回退实例,失败原因会以 `warn` 级别记录。 + +由于 `debugging` 会向调用方暴露实例名称,生产环境应保持关闭,改用日志排查。 diff --git a/t/plugin/ai-proxy-semantic-routing.t b/t/plugin/ai-proxy-semantic-routing.t new file mode 100644 index 000000000000..5c29bec17e6e --- /dev/null +++ b/t/plugin/ai-proxy-semantic-routing.t @@ -0,0 +1,980 @@ +# +# 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} + elseif text:find("translate") or text:find("summar") then + return {0, 1} + end + return {0.6, 0.6} + 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 + -- 3-D vector vs the 2-D reference vectors + data[i] = { index = i - 1, embedding = {1, 0, 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"), + }, + 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":"what is the weather today"}]} +--- 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+\)/ +--- 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"), + }, + 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-Route: code +X-AI-Semantic-Scores: code:1\.\d+,cheap:0\.\d+ +--- 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"), + }, + 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"), + }, + 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 scoring + -- ~0.707 clears the global one but not the instance's + 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", + }, + }, + }, + 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":"what is the weather today"}]} +--- 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"), + }, + 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-Route: code +X-AI-Semantic-Scores: code:1\.\d+,cheap:0\.\d+ +--- 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"), + }, + 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-Route: code +X-AI-Semantic-Scores: code:1\.\d+,cheap:0\.\d+ + + + +=== 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", + }, + }, + }, + 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] diff --git a/t/plugin/ai-proxy-semantic-schema.t b/t/plugin/ai-proxy-semantic-schema.t new file mode 100644 index 000000000000..3be5041817ec --- /dev/null +++ b/t/plugin/ai-proxy-semantic-schema.t @@ -0,0 +1,468 @@ +# +# 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"); + } +}); + +run_tests(); + +__DATA__ + +=== TEST 1: valid semantic config is accepted +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/routes/1', ngx.HTTP_PUT, [[{ + "uri": "/anything", + "plugins": { + "ai-proxy-multi": { + "balancer": { "algorithm": "semantic" }, + "semantic_opts": { + "embeddings": { + "provider": "openai", + "model": "text-embedding-3-small", + "auth": { "header": { "Authorization": "Bearer sk-emb" } } + }, + "threshold": 0.75, + "fallback": "default" + }, + "instances": [ + { + "name": "code", "provider": "openai", "weight": 1, + "auth": { "header": { "Authorization": "Bearer token" } }, + "options": { "model": "gpt-4o" }, + "examples": ["write a python function", "debug this code"], + "threshold": 0.85 + }, + { + "name": "default", "provider": "openai", "weight": 1, + "auth": { "header": { "Authorization": "Bearer token" } }, + "options": { "model": "gpt-4o-mini" } + } + ], + "ssl_verify": false + } + } + }]]) + if code >= 300 then + ngx.status = code + ngx.say(body) + return + end + ngx.say("passed") + } + } +--- response_body +passed +--- no_error_log +[error] + + + +=== TEST 2: semantic requires examples on a non-fallback instance +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/routes/1', ngx.HTTP_PUT, [[{ + "uri": "/anything", + "plugins": { + "ai-proxy-multi": { + "balancer": { "algorithm": "semantic" }, + "semantic_opts": { + "embeddings": { + "provider": "openai", "model": "text-embedding-3-small", + "auth": { "header": { "Authorization": "Bearer sk-emb" } } + } + }, + "instances": [ + { + "name": "code", "provider": "openai", "weight": 1, + "auth": { "header": { "Authorization": "Bearer token" } }, + "options": { "model": "gpt-4o" } + } + ], + "ssl_verify": false + } + } + }]]) + ngx.status = code + ngx.say(body) + } + } +--- error_code: 400 +--- response_body_like: examples + + + +=== TEST 3: semantic requires semantic_opts.embeddings config +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/routes/1', ngx.HTTP_PUT, [[{ + "uri": "/anything", + "plugins": { + "ai-proxy-multi": { + "balancer": { "algorithm": "semantic" }, + "instances": [ + { + "name": "code", "provider": "openai", "weight": 1, + "auth": { "header": { "Authorization": "Bearer token" } }, + "options": { "model": "gpt-4o" }, + "examples": ["write code"] + } + ], + "ssl_verify": false + } + } + }]]) + ngx.status = code + ngx.say(body) + } + } +--- error_code: 400 +--- response_body_like: embeddings + + + +=== TEST 4: semantic_opts.fallback must name an existing instance +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/routes/1', ngx.HTTP_PUT, [[{ + "uri": "/anything", + "plugins": { + "ai-proxy-multi": { + "balancer": { "algorithm": "semantic" }, + "semantic_opts": { + "embeddings": { + "provider": "openai", "model": "text-embedding-3-small", + "auth": { "header": { "Authorization": "Bearer sk-emb" } } + }, + "fallback": "nope" + }, + "instances": [ + { + "name": "a", "provider": "openai", "weight": 1, + "auth": { "header": { "Authorization": "Bearer token" } }, + "options": { "model": "gpt-4o" }, + "examples": ["write code"] + } + ], + "ssl_verify": false + } + } + }]]) + ngx.status = code + ngx.say(body) + } + } +--- error_code: 400 +--- response_body_like: unknown instance + + + +=== TEST 5: azure-openai embeddings require an endpoint +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/routes/1', ngx.HTTP_PUT, [[{ + "uri": "/anything", + "plugins": { + "ai-proxy-multi": { + "balancer": { "algorithm": "semantic" }, + "semantic_opts": { + "embeddings": { + "provider": "azure-openai", "model": "text-embedding-3-small", + "auth": { "header": { "api-key": "sk-emb" } } + } + }, + "instances": [ + { + "name": "a", "provider": "openai", "weight": 1, + "auth": { "header": { "Authorization": "Bearer token" } }, + "options": { "model": "gpt-4o" }, + "examples": ["write code"] + } + ], + "ssl_verify": false + } + } + }]]) + ngx.status = code + ngx.say(body) + } + } +--- error_code: 400 +--- response_body_like: endpoint + + + +=== TEST 6: embeddings.model is required +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/routes/1', ngx.HTTP_PUT, [[{ + "uri": "/anything", + "plugins": { + "ai-proxy-multi": { + "balancer": { "algorithm": "semantic" }, + "semantic_opts": { + "embeddings": { + "provider": "openai", + "auth": { "header": { "Authorization": "Bearer sk-emb" } } + } + }, + "instances": [ + { + "name": "a", "provider": "openai", "weight": 1, + "auth": { "header": { "Authorization": "Bearer token" } }, + "options": { "model": "gpt-4o" }, + "examples": ["write code"] + } + ], + "ssl_verify": false + } + } + }]]) + ngx.status = code + ngx.say(body) + } + } +--- error_code: 400 +--- response_body_like: model + + + +=== TEST 7: only the named fallback is exempt from the examples requirement +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/routes/1', ngx.HTTP_PUT, [[{ + "uri": "/anything", + "plugins": { + "ai-proxy-multi": { + "balancer": { "algorithm": "semantic" }, + "semantic_opts": { + "embeddings": { + "provider": "openai", "model": "text-embedding-3-small", + "auth": { "header": { "Authorization": "Bearer sk-emb" } } + }, + "fallback": "default" + }, + "instances": [ + { + "name": "a", "provider": "openai", "weight": 1, + "auth": { "header": { "Authorization": "Bearer token" } }, + "options": { "model": "gpt-4o" } + }, + { + "name": "default", "provider": "openai", "weight": 1, + "auth": { "header": { "Authorization": "Bearer token" } }, + "options": { "model": "gpt-4o-mini" } + } + ], + "ssl_verify": false + } + } + }]]) + ngx.status = code + ngx.say(body) + } + } +--- error_code: 400 +--- response_body_like: examples + + + +=== TEST 8: azure-openai embeddings endpoint must include the deployment path +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/routes/1', ngx.HTTP_PUT, [[{ + "uri": "/anything", + "plugins": { + "ai-proxy-multi": { + "balancer": { "algorithm": "semantic" }, + "semantic_opts": { + "embeddings": { + "provider": "azure-openai", "model": "text-embedding-3-small", + "endpoint": "https://my.openai.azure.com", + "auth": { "header": { "api-key": "sk-emb" } } + } + }, + "instances": [ + { + "name": "a", "provider": "openai", "weight": 1, + "auth": { "header": { "Authorization": "Bearer token" } }, + "options": { "model": "gpt-4o" }, + "examples": ["write code"] + } + ], + "ssl_verify": false + } + } + }]]) + ngx.status = code + ngx.say(body) + } + } +--- error_code: 400 +--- response_body_like: full deployment path + + + +=== TEST 9: embeddings.auth rejects ctx-dependent schemes +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/routes/1', ngx.HTTP_PUT, [[{ + "uri": "/anything", + "plugins": { + "ai-proxy-multi": { + "balancer": { "algorithm": "semantic" }, + "semantic_opts": { + "embeddings": { + "provider": "openai", "model": "text-embedding-3-small", + "auth": { "gcp": { "service_account_json": "{}" } } + } + }, + "instances": [ + { + "name": "a", "provider": "openai", "weight": 1, + "auth": { "header": { "Authorization": "Bearer token" } }, + "options": { "model": "gpt-4o" }, + "examples": ["write code"] + } + ], + "ssl_verify": false + } + } + }]]) + ngx.status = code + ngx.say(body) + } + } +--- error_code: 400 +--- response_body_like: gcp + + + +=== TEST 10: embeddings.endpoint must carry a scheme and host +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/routes/1', ngx.HTTP_PUT, [[{ + "uri": "/anything", + "plugins": { + "ai-proxy-multi": { + "balancer": { "algorithm": "semantic" }, + "semantic_opts": { + "embeddings": { + "provider": "openai", "model": "text-embedding-3-small", + "endpoint": "my.embeddings.host/v1/embeddings", + "auth": { "header": { "Authorization": "Bearer sk" } } + } + }, + "instances": [ + { + "name": "a", "provider": "openai", "weight": 1, + "auth": { "header": { "Authorization": "Bearer token" } }, + "options": { "model": "gpt-4o" }, + "examples": ["write code"] + } + ], + "ssl_verify": false + } + } + }]]) + ngx.status = code + ngx.say(body) + } + } +--- error_code: 400 +--- response_body_like: semantic_opts.embeddings.endpoint + + + +=== TEST 11: the fallback instance may also carry examples and rank +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/routes/1', ngx.HTTP_PUT, [[{ + "uri": "/anything", + "plugins": { + "ai-proxy-multi": { + "balancer": { "algorithm": "semantic" }, + "semantic_opts": { + "embeddings": { + "provider": "openai", "model": "text-embedding-3-small", + "auth": { "header": { "Authorization": "Bearer sk-emb" } } + }, + "fallback": "generalist" + }, + "instances": [ + { + "name": "code", "provider": "openai", "weight": 1, + "auth": { "header": { "Authorization": "Bearer token" } }, + "options": { "model": "gpt-4o" }, + "examples": ["write a python function"] + }, + { + "name": "generalist", "provider": "openai", "weight": 1, + "auth": { "header": { "Authorization": "Bearer token" } }, + "options": { "model": "gpt-4o-mini" }, + "examples": ["answer a general question"] + } + ], + "ssl_verify": false + } + } + }]]) + if code >= 300 then + ngx.status = code + ngx.say(body) + return + end + ngx.say("passed") + } + } +--- response_body +passed +--- no_error_log +[error] diff --git a/t/plugin/ai-proxy-semantic.t b/t/plugin/ai-proxy-semantic.t new file mode 100644 index 000000000000..5fe9e0fa65a7 --- /dev/null +++ b/t/plugin/ai-proxy-semantic.t @@ -0,0 +1,67 @@ +# +# 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(); + +run_tests(); + +__DATA__ + +=== TEST 1: semantic math helpers (normalize / dot / cosine / max) +--- config + location /t { + content_by_lua_block { + local s = require("apisix.plugins.ai-proxy.semantic") + local function approx(a, b) return math.abs(a - b) < 1e-9 end + + -- normalize + local n = s.normalize({3, 4}) + assert(approx(n[1], 0.6) and approx(n[2], 0.8), "normalize unit vector") + local z = s.normalize({0, 0}) + assert(approx(z[1], 0) and approx(z[2], 0), "normalize zero vector") + + -- cosine + assert(approx(s.cosine({1, 0}, {1, 0}), 1.0), "cosine identical") + assert(approx(s.cosine({1, 0}, {0, 1}), 0.0), "cosine orthogonal") + assert(approx(s.cosine({1, 1}, {2, 2}), 1.0), "cosine same direction") + assert(approx(s.cosine({1, 0}, {-1, 0}), -1.0), "cosine opposite") + + -- dot on normalized == cosine + local a = s.normalize({1, 2, 3}) + assert(approx(s.dot(a, a), 1.0), "dot of normalized identical") + + -- max: an instance scores by its best-matching example, so one + -- dead-on example is not diluted by the instance's broader ones + assert(approx(s.max({0.7, 0.85}), 0.85), "max picks the best") + assert(approx(s.max({1.0, 0.5, 0.4}), 1.0), "max is not diluted") + assert(approx(s.max({0.5}), 0.5), "max of a single score") + assert(s.max({}) == nil, "max of empty is nil") + + ngx.say("passed") + } + } +--- request +GET /t +--- response_body +passed +--- no_error_log +[error]