Files
2026-07-13 12:32:21 +08:00

262 lines
7.4 KiB
Lua

local ai_shared = require("kong.llm.drivers.shared")
local re_match = ngx.re.match
local cjson = require("cjson.safe")
local fmt = string.format
local EMPTY = require("kong.tools.table").EMPTY
local EMPTY_ARRAY = {
EMPTY,
}
-- The module table
local _M = {
config_schema = require "kong.llm.schemas",
}
do
-- formats_compatible is a map of formats that are compatible with each other.
local formats_compatible = {
["llm/v1/chat"] = {
["llm/v1/chat"] = true,
},
["llm/v1/completions"] = {
["llm/v1/completions"] = true,
},
}
-- identify_request determines the format of the request.
-- It returns the format, or nil and an error message.
-- @tparam table request The request to identify
-- @treturn[1] string The format of the request
-- @treturn[2] nil
-- @treturn[2] string An error message if unidentified, or matching multiple formats
local function identify_request(request)
-- primitive request format determination
local formats = {}
if type(request.messages) == "table" and #request.messages > 0 then
table.insert(formats, "llm/v1/chat")
end
if type(request.prompt) == "string" then
table.insert(formats, "llm/v1/completions")
end
if formats[2] then
return nil, "request matches multiple LLM request formats"
elseif not formats_compatible[formats[1] or false] then
return nil, "request format not recognised"
else
return formats[1]
end
end
--- Check if a request is compatible with a route type.
-- @tparam table request The request to check
-- @tparam string route_type The route type to check against, eg. "llm/v1/chat"
-- @treturn[1] boolean True if compatible
-- @treturn[2] boolean False if not compatible
-- @treturn[2] string Error message if not compatible
-- @treturn[3] nil
-- @treturn[3] string Error message if request format is not recognised
function _M.is_compatible(request, route_type)
if route_type == "preserve" then
return true
end
local format, err = identify_request(request)
if err then
return nil, err
end
if formats_compatible[format][route_type] then
return true
end
return false, fmt("[%s] message format is not compatible with [%s] route type", format, route_type)
end
end
do
------------------------------------------------------------------------------
-- LLM class implementation
------------------------------------------------------------------------------
local LLM = {}
LLM.__index = LLM
function LLM:ai_introspect_body(request, system_prompt, http_opts, response_regex_match)
local err, _
-- set up the LLM request for transformation instructions
local ai_request
-- mistral, cohere, titan (via Bedrock) don't support system commands
if self.conf.model.provider == "bedrock" then
for _, p in ipairs(self.driver.bedrock_unsupported_system_role_patterns) do
if self.conf.model.name:find(p) then
ai_request = {
messages = {
[1] = {
role = "user",
content = system_prompt,
},
[2] = {
role = "assistant",
content = "What is the message?",
},
[3] = {
role = "user",
content = request,
}
},
stream = false,
}
break
end
end
end
-- not Bedrock, or didn't match banned pattern - continue as normal
if not ai_request then
ai_request = {
messages = {
[1] = {
role = "system",
content = system_prompt,
},
[2] = {
role = "user",
content = request,
}
},
stream = false,
}
end
-- convert it to the specified driver format
ai_request, _, err = self.driver.to_format(ai_request, self.conf.model, "llm/v1/chat")
if err then
return nil, err
end
-- run the shared logging/analytics/auth function
ai_shared.pre_request(self.conf, ai_request)
-- send it to the ai service
local ai_response, _, err = self.driver.subrequest(ai_request, self.conf, http_opts, false, self.identity_interface)
if err then
return nil, "failed to introspect request with AI service: " .. err
end
-- parse and convert the response
local ai_response, err, _ = self.driver.from_format(ai_response, self.conf.model, self.conf.route_type)
if err then
return nil, "failed to convert AI response to Kong format: " .. err
end
-- run the shared logging/analytics function
ai_shared.post_request(self.conf, ai_response)
local ai_response, err = cjson.decode(ai_response)
if err then
return nil, "failed to convert AI response to JSON: " .. err
end
local new_request_body = (((ai_response.choices or EMPTY_ARRAY)[1] or EMPTY).message or EMPTY).content
if not new_request_body then
return nil, "no 'choices' in upstream AI service response"
end
-- if specified, extract the first regex match from the AI response
-- this is useful for AI models that pad with assistant text, even when
-- we ask them NOT to.
if response_regex_match then
local matches, err = re_match(new_request_body, response_regex_match, "ijom")
if err then
return nil, "failed regex matching ai response: " .. err
end
if matches then
new_request_body = matches[0] -- this array DOES start at 0, for some reason
else
return nil, "AI response did not match specified regular expression"
end
end
return new_request_body
end
-- Parse the response instructions.
-- @tparam string|table in_body The response to parse, if a string, it will be parsed as JSON.
-- @treturn[1] table The headers, field `in_body.headers`
-- @treturn[1] string The body, field `in_body.body` (or if absent `in_body` itself as a table)
-- @treturn[1] number The status, field `in_body.status` (or 200 if absent)
-- @treturn[2] nil
-- @treturn[2] string An error message if parsing failed or input wasn't a table
function LLM:parse_json_instructions(in_body)
local err
if type(in_body) == "string" then
in_body, err = cjson.decode(in_body)
if err then
return nil, nil, nil, err
end
end
if type(in_body) ~= "table" then
return nil, nil, nil, "input not table or string"
end
return
in_body.headers,
in_body.body or in_body,
in_body.status or 200
end
--- Instantiate a new LLM driver instance.
-- @tparam table conf Configuration table
-- @tparam table http_opts HTTP options table
-- @tparam table [optional] cloud-authentication identity interface
-- @treturn[1] table A new LLM driver instance
-- @treturn[2] nil
-- @treturn[2] string An error message if instantiation failed
function _M.new_driver(conf, http_opts, identity_interface)
local self = {
conf = conf or {},
http_opts = http_opts or {},
identity_interface = identity_interface, -- 'or nil'
}
setmetatable(self, LLM)
self.provider = (self.conf.model or {}).provider or "NONE_SET"
local driver_module = "kong.llm.drivers." .. self.provider
local ok
ok, self.driver = pcall(require, driver_module)
if not ok then
local err = "could not instantiate " .. driver_module .. " package"
kong.log.err(err)
return nil, err
end
return self
end
end
return _M