blob: ca09336aecd177d2221993622dab68007a9bbead [file]
--
-- Licensed to the Apache Software Foundation (ASF) under one or more
-- contributor license agreements. See the NOTICE file distributed with
-- this work for additional information regarding copyright ownership.
-- The ASF licenses this file to You under the Apache License, Version 2.0
-- (the "License"); you may not use this file except in compliance with
-- the License. You may obtain a copy of the License at
--
-- http://www.apache.org/licenses/LICENSE-2.0
--
-- Unless required by applicable law or agreed to in writing, software
-- distributed under the License is distributed on an "AS IS" BASIS,
-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-- See the License for the specific language governing permissions and
-- limitations under the License.
--
-- 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. It sets none of the
-- opts.client_* fields, so build_request forwards none of the client's headers
-- (Authorization, Cookie, ...) or query to the embedding provider, and the pure
-- request client always sends our { model, input } body rather than the
-- client's request body.
local extra_opts = {
endpoint = conf.endpoint,
auth = conf.auth,
target_path = target_path,
}
local req_conf = {
ssl_verify = conf.ssl_verify ~= false,
timeout = conf.timeout or 3000,
keepalive = true,
}
local status, raw_body, err = ai_provider:request(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