1066 lines
33 KiB
Lua
1066 lines
33 KiB
Lua
local _M = {}
|
|
|
|
-- imports
|
|
local cjson = require("cjson.safe")
|
|
local http = require("resty.http")
|
|
local fmt = string.format
|
|
local os = os
|
|
local parse_url = require("socket.url").parse
|
|
local aws_stream = require("kong.tools.aws_stream")
|
|
local ai_plugin_ctx = require("kong.llm.plugin.ctx")
|
|
local ai_plugin_o11y = require("kong.llm.plugin.observability")
|
|
--
|
|
|
|
-- static
|
|
local ipairs = ipairs
|
|
local str_find = string.find
|
|
local str_sub = string.sub
|
|
local split = require("kong.tools.string").split
|
|
local splitn = require("kong.tools.string").splitn
|
|
|
|
local function str_ltrim(s) -- remove leading whitespace from string.
|
|
return type(s) == "string" and s:gsub("^%s*", "")
|
|
end
|
|
--
|
|
|
|
local log_entry_keys = {
|
|
USAGE_CONTAINER = "usage",
|
|
META_CONTAINER = "meta",
|
|
PAYLOAD_CONTAINER = "payload",
|
|
CACHE_CONTAINER = "cache",
|
|
|
|
-- payload keys
|
|
REQUEST_BODY = "request",
|
|
RESPONSE_BODY = "response",
|
|
|
|
-- meta keys
|
|
PLUGIN_ID = "plugin_id",
|
|
PROVIDER_NAME = "provider_name",
|
|
REQUEST_MODEL = "request_model",
|
|
RESPONSE_MODEL = "response_model",
|
|
LLM_LATENCY = "llm_latency",
|
|
|
|
-- usage keys
|
|
PROMPT_TOKENS = "prompt_tokens",
|
|
COMPLETION_TOKENS = "completion_tokens",
|
|
TOTAL_TOKENS = "total_tokens",
|
|
TIME_PER_TOKEN = "time_per_token",
|
|
COST = "cost",
|
|
|
|
-- cache keys
|
|
VECTOR_DB = "vector_db",
|
|
EMBEDDINGS_PROVIDER = "embeddings_provider",
|
|
EMBEDDINGS_MODEL = "embeddings_model",
|
|
CACHE_STATUS = "cache_status",
|
|
}
|
|
|
|
local openai_override = os.getenv("OPENAI_TEST_PORT")
|
|
|
|
---- IDENTITY SETTINGS
|
|
local GCP_SERVICE_ACCOUNT do
|
|
GCP_SERVICE_ACCOUNT = os.getenv("GCP_SERVICE_ACCOUNT")
|
|
end
|
|
|
|
local GCP = require("resty.gcp.request.credentials.accesstoken")
|
|
local aws_config = require "resty.aws.config" -- reads environment variables whilst available
|
|
local AWS = require("resty.aws")
|
|
local AWS_REGION do
|
|
AWS_REGION = os.getenv("AWS_REGION") or os.getenv("AWS_DEFAULT_REGION")
|
|
end
|
|
----
|
|
|
|
_M._CONST = {
|
|
["SSE_TERMINATOR"] = "[DONE]",
|
|
["AWS_STREAM_CONTENT_TYPE"] = "application/vnd.amazon.eventstream",
|
|
["GEMINI_STREAM_CONTENT_TYPE"] = "application/json",
|
|
}
|
|
|
|
_M._SUPPORTED_STREAMING_CONTENT_TYPES = {
|
|
["text/event-stream"] = true,
|
|
["application/vnd.amazon.eventstream"] = true,
|
|
["application/json"] = true,
|
|
["application/stream+json"] = true,
|
|
["application/x-ndjson"] = true,
|
|
}
|
|
|
|
_M.streaming_has_token_counts = {
|
|
["cohere"] = true,
|
|
["llama2"] = true,
|
|
["anthropic"] = true,
|
|
["gemini"] = true,
|
|
["bedrock"] = true,
|
|
}
|
|
|
|
_M.upstream_url_format = {
|
|
openai = fmt("%s://api.openai.com:%s", openai_override and "http" or "https", openai_override or "443"),
|
|
anthropic = "https://api.anthropic.com:443",
|
|
cohere = "https://api.cohere.com:443",
|
|
azure = fmt("%s://%%s.openai.azure.com:%s/openai/deployments/%%s", openai_override and "http" or "https", openai_override or "443"),
|
|
gemini = "https://generativelanguage.googleapis.com",
|
|
gemini_vertex = "https://%s",
|
|
bedrock = "https://bedrock-runtime.%s.amazonaws.com",
|
|
mistral = "https://api.mistral.ai:443",
|
|
huggingface = "https://api-inference.huggingface.co/models/%s",
|
|
}
|
|
|
|
_M.operation_map = {
|
|
openai = {
|
|
["llm/v1/completions"] = {
|
|
path = "/v1/completions",
|
|
method = "POST",
|
|
},
|
|
["llm/v1/chat"] = {
|
|
path = "/v1/chat/completions",
|
|
method = "POST",
|
|
},
|
|
},
|
|
anthropic = {
|
|
["llm/v1/completions"] = {
|
|
path = "/v1/complete",
|
|
method = "POST",
|
|
},
|
|
["llm/v1/chat"] = {
|
|
path = "/v1/messages",
|
|
method = "POST",
|
|
},
|
|
},
|
|
cohere = {
|
|
["llm/v1/completions"] = {
|
|
path = "/v1/generate",
|
|
method = "POST",
|
|
},
|
|
["llm/v1/chat"] = {
|
|
path = "/v1/chat",
|
|
method = "POST",
|
|
},
|
|
},
|
|
azure = {
|
|
["llm/v1/completions"] = {
|
|
path = "/completions",
|
|
method = "POST",
|
|
},
|
|
["llm/v1/chat"] = {
|
|
path = "/chat/completions",
|
|
method = "POST",
|
|
},
|
|
},
|
|
gemini = {
|
|
["llm/v1/chat"] = {
|
|
path = "/v1beta/models/%s:%s",
|
|
method = "POST",
|
|
},
|
|
},
|
|
gemini_vertex = {
|
|
["llm/v1/chat"] = {
|
|
path = "/v1/projects/%s/locations/%s/publishers/google/models/%s:%s",
|
|
},
|
|
},
|
|
mistral = {
|
|
["llm/v1/chat"] = {
|
|
path = "v1/chat/completions",
|
|
method = "POST",
|
|
},
|
|
},
|
|
huggingface = {
|
|
["llm/v1/completions"] = {
|
|
path = "/models/%s",
|
|
method = "POST",
|
|
},
|
|
["llm/v1/chat"] = {
|
|
path = "/models/%s",
|
|
method = "POST",
|
|
},
|
|
},
|
|
bedrock = {
|
|
["llm/v1/chat"] = {
|
|
path = "/model/%s/%s",
|
|
method = "POST",
|
|
},
|
|
},
|
|
}
|
|
|
|
_M.clear_response_headers = {
|
|
shared = { -- deprecared, not using
|
|
"Content-Length",
|
|
},
|
|
openai = {
|
|
"Set-Cookie",
|
|
},
|
|
azure = {
|
|
"Set-Cookie",
|
|
},
|
|
mistral = {
|
|
"Set-Cookie",
|
|
},
|
|
gemini = {
|
|
"Set-Cookie",
|
|
},
|
|
bedrock = {
|
|
"Set-Cookie",
|
|
},
|
|
}
|
|
|
|
---
|
|
-- Takes an already 'standardised' input, and merges
|
|
-- any missing fields with their defaults as defined
|
|
-- in the plugin config.
|
|
--
|
|
-- It it supposed to be completely provider-agnostic,
|
|
-- and only operate to assist the Kong operator to
|
|
-- allow their users and admins to define a pre-runed
|
|
-- set of default options for any AI inference request.
|
|
--
|
|
-- @param {table} request kong-format inference request conforming to one of many supported formats
|
|
-- @param {table} options the 'config.model.options' table from any Kong AI plugin
|
|
-- @return {table} the input 'request' table, but with (missing) default options merged in
|
|
-- @return {string} error if any is thrown - request should definitely be terminated if this is not nil
|
|
function _M.merge_config_defaults(request, options, request_format)
|
|
if options then
|
|
request.temperature = options.temperature or request.temperature
|
|
request.max_tokens = options.max_tokens or request.max_tokens
|
|
request.top_p = options.top_p or request.top_p
|
|
request.top_k = options.top_k or request.top_k
|
|
end
|
|
|
|
return request, nil
|
|
end
|
|
|
|
local function handle_stream_event(event_table, model_info, route_type)
|
|
if event_table.done then
|
|
-- return analytics table
|
|
return "[DONE]", nil, {
|
|
prompt_tokens = event_table.prompt_eval_count or 0,
|
|
completion_tokens = event_table.eval_count or 0,
|
|
}
|
|
|
|
else
|
|
-- parse standard response frame
|
|
if route_type == "stream/llm/v1/chat" then
|
|
return {
|
|
choices = {
|
|
[1] = {
|
|
delta = {
|
|
content = event_table.message and event_table.message.content or "",
|
|
},
|
|
index = 0,
|
|
},
|
|
},
|
|
model = event_table.model,
|
|
object = "chat.completion.chunk",
|
|
}
|
|
|
|
elseif route_type == "stream/llm/v1/completions" then
|
|
return {
|
|
choices = {
|
|
[1] = {
|
|
text = event_table.response or "",
|
|
index = 0,
|
|
},
|
|
},
|
|
model = event_table.model,
|
|
object = "text_completion",
|
|
}
|
|
|
|
end
|
|
end
|
|
end
|
|
|
|
---
|
|
-- Manages cloud SDKs, for using "workload identity" authentications,
|
|
-- that are tied to this specific plugin in-memory.
|
|
--
|
|
-- This allows users to run different authentication configurations
|
|
-- between different AI Plugins.
|
|
--
|
|
-- @param {table} this_cache self - stores all the SDK instances
|
|
-- @param {table} plugin_config the configuration to cache against and also provide SDK settings with
|
|
-- @return {table} self
|
|
_M.cloud_identity_function = function(this_cache, plugin_config)
|
|
if plugin_config.model.provider == "gemini" and
|
|
plugin_config.auth and
|
|
plugin_config.auth.gcp_use_service_account then
|
|
|
|
ngx.log(ngx.DEBUG, "loading gcp sdk for plugin ", kong.plugin.get_id())
|
|
|
|
local service_account_json = (plugin_config.auth and plugin_config.auth.gcp_service_account_json) or GCP_SERVICE_ACCOUNT
|
|
|
|
local ok, gcp_auth = pcall(GCP.new, nil, service_account_json)
|
|
if ok and gcp_auth then
|
|
-- store our item for the next time we need it
|
|
gcp_auth.service_account_json = service_account_json
|
|
this_cache[plugin_config] = { interface = gcp_auth, error = nil }
|
|
return this_cache[plugin_config]
|
|
end
|
|
|
|
return { interface = nil, error = "cloud-authentication with GCP failed" }
|
|
|
|
elseif plugin_config.model.provider == "bedrock" then
|
|
ngx.log(ngx.DEBUG, "loading aws sdk for plugin ", kong.plugin.get_id())
|
|
local aws
|
|
|
|
local region = plugin_config.model.options
|
|
and plugin_config.model.options.bedrock
|
|
and plugin_config.model.options.bedrock.aws_region
|
|
or AWS_REGION
|
|
|
|
if not region then
|
|
return { interface = nil, error = "AWS region not specified anywhere" }
|
|
end
|
|
|
|
local access_key_set = (plugin_config.auth and plugin_config.auth.aws_access_key_id)
|
|
or aws_config.global.AWS_ACCESS_KEY_ID
|
|
local secret_key_set = plugin_config.auth and plugin_config.auth.aws_secret_access_key
|
|
or aws_config.global.AWS_SECRET_ACCESS_KEY
|
|
|
|
aws = AWS({
|
|
-- if any of these are nil, they either use the SDK default or
|
|
-- are deliberately null so that a different auth chain is used
|
|
region = region,
|
|
})
|
|
|
|
if access_key_set and secret_key_set then
|
|
-- Override credential config according to plugin config, if set
|
|
local creds = aws:Credentials {
|
|
accessKeyId = access_key_set,
|
|
secretAccessKey = secret_key_set,
|
|
}
|
|
|
|
aws.config.credentials = creds
|
|
end
|
|
|
|
this_cache[plugin_config] = { interface = aws, error = nil }
|
|
|
|
return this_cache[plugin_config]
|
|
end
|
|
end
|
|
|
|
|
|
local function json_array_iterator(input_str, prev_state)
|
|
local state = prev_state or {
|
|
started = false,
|
|
pos = 1,
|
|
input = input_str,
|
|
eof = false,
|
|
}
|
|
|
|
if state.eof then
|
|
error("Iterator has reached end of input")
|
|
end
|
|
|
|
-- If new input provided, append it to existing input
|
|
if prev_state and input_str then
|
|
state.input = state.input:sub(state.pos) .. input_str
|
|
state.pos = 1
|
|
end
|
|
|
|
local len = #state.input
|
|
|
|
-- Handle array start
|
|
if not state.started then
|
|
-- Skip whitespace
|
|
while state.pos <= len and state.input:sub(state.pos, state.pos):match("%s") do
|
|
state.pos = state.pos + 1
|
|
end
|
|
if state.pos > len or state.input:sub(state.pos, state.pos) ~= "[" then
|
|
error("Invalid start: expected '['")
|
|
end
|
|
state.started = true
|
|
state.pos = state.pos + 1
|
|
end
|
|
|
|
-- Skip whitespace
|
|
while state.pos <= len and state.input:sub(state.pos, state.pos):match("%s") do
|
|
state.pos = state.pos + 1
|
|
end
|
|
|
|
return function()
|
|
-- Find next complete element using bracket matching
|
|
local start_pos = state.pos
|
|
local brace_count = 0
|
|
local bracket_count = 0
|
|
local in_string = false
|
|
local escape_next = false
|
|
|
|
while state.pos <= len do
|
|
local char = state.input:sub(state.pos, state.pos)
|
|
|
|
-- Handle string literals
|
|
if char == '"' and not escape_next then
|
|
in_string = not in_string
|
|
end
|
|
|
|
-- Handle escape sequences
|
|
if char == '\\' and not escape_next then
|
|
escape_next = true
|
|
else
|
|
escape_next = false
|
|
end
|
|
|
|
local delimiter
|
|
|
|
-- Count braces and brackets when not in string
|
|
if not in_string then
|
|
if char == '{' then
|
|
brace_count = brace_count + 1
|
|
elseif char == '}' then
|
|
brace_count = brace_count - 1
|
|
elseif char == '[' then
|
|
bracket_count = bracket_count + 1
|
|
elseif char == ']' then
|
|
bracket_count = bracket_count - 1
|
|
-- Found element delimiter by top level closing bracket
|
|
if brace_count == 0 and bracket_count == -1 then
|
|
delimiter = state.pos - 1
|
|
state.eof = true
|
|
end
|
|
elseif char == ',' and brace_count == 0 and bracket_count == 0 then
|
|
-- Found element delimiter at top level
|
|
delimiter = state.pos - 1
|
|
-- if delimiter is at start of string, skip it in next iteration
|
|
if state.pos == 1 then
|
|
start_pos = 2
|
|
end
|
|
elseif brace_count == 0 and bracket_count == 0 and state.pos == len then
|
|
-- Found element delimiter at end of string
|
|
delimiter = state.pos
|
|
end
|
|
|
|
if delimiter and start_pos < len and delimiter >= start_pos then
|
|
state.pos = state.pos + 1 -- move past delimeter
|
|
local element = state.input:sub(start_pos, delimiter)
|
|
-- strip starting and trailing whitespace
|
|
element = element:gsub("^%s*(.-)%s*$", "%1")
|
|
if element and element ~= "" then
|
|
return element, state
|
|
end
|
|
end
|
|
end
|
|
|
|
state.pos = state.pos + 1
|
|
end
|
|
|
|
-- If we reach here, we need more data
|
|
state.pos = start_pos
|
|
return nil, state
|
|
end
|
|
end
|
|
|
|
---
|
|
-- Splits a HTTPS data chunk or frame into individual
|
|
-- SSE-format messages, see:
|
|
-- https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events#event_stream_format
|
|
--
|
|
-- For compatibility, it also looks for the first character being '{' which
|
|
-- indicates that the input is not text/event-stream format, but instead a chunk
|
|
-- of delimited application/json, which some providers return, in which case
|
|
-- it simply splits the frame into separate JSON messages and appends 'data: '
|
|
-- as if it were an SSE message.
|
|
--
|
|
-- @param {string} frame input string to format into SSE events
|
|
-- @param {string} content_type sets parser
|
|
-- @return {table} n number of split SSE messages, or empty table
|
|
function _M.frame_to_events(frame, content_type)
|
|
local events = {}
|
|
|
|
if (not frame) or (#frame < 1) or (type(frame)) ~= "string" then
|
|
return
|
|
end
|
|
|
|
-- some new LLMs return the JSON object-by-object,
|
|
-- because that totally makes sense to parse?!
|
|
if content_type == _M._CONST.GEMINI_STREAM_CONTENT_TYPE then
|
|
for element, new_state in json_array_iterator(frame, kong.ctx.plugin.gemini_state) do
|
|
kong.ctx.plugin.gemini_state = new_state
|
|
if element then
|
|
local _, err = cjson.decode(element)
|
|
if err then
|
|
kong.log.err("malformed JSON in gemini stream: ", err, ": ", element)
|
|
end
|
|
events[#events+1] = { data = element }
|
|
end
|
|
if new_state.eof then -- array end
|
|
kong.ctx.plugin.gemini_state = nil
|
|
events[#events+1] = { data = _M._CONST.SSE_TERMINATOR }
|
|
return events
|
|
end
|
|
end
|
|
|
|
elseif content_type == _M._CONST.AWS_STREAM_CONTENT_TYPE then
|
|
local parser = aws_stream:new(frame)
|
|
while true do
|
|
local msg = parser:next_message()
|
|
|
|
if not msg then
|
|
break
|
|
end
|
|
|
|
events[#events+1] = { data = cjson.encode(msg) }
|
|
end
|
|
|
|
-- check if it's raw json and just return the split up data frame
|
|
-- Cohere / Other flat-JSON format parser
|
|
-- just return the split up data frame
|
|
elseif (not kong or (not kong.ctx.plugin.gemini_state and not kong.ctx.plugin.truncated_frame)) and string.sub(str_ltrim(frame), 1, 1) == "{" then
|
|
for event in frame:gmatch("[^\r\n]+") do
|
|
events[#events + 1] = {
|
|
data = event,
|
|
}
|
|
end
|
|
|
|
-- standard SSE parser
|
|
else
|
|
local event_lines, count = splitn(frame, "\n")
|
|
local struct = {} -- { event = nil, id = nil, data = nil }
|
|
|
|
for i, dat in ipairs(event_lines) do
|
|
if dat == "" then
|
|
events[#events + 1] = struct
|
|
struct = {} -- { event = nil, id = nil, data = nil }
|
|
end
|
|
|
|
-- test for truncated chunk on the last line (no trailing \r\n\r\n)
|
|
if dat ~= "" and count == i then
|
|
ngx.log(ngx.DEBUG, "[ai-proxy] truncated sse frame head")
|
|
if kong then
|
|
kong.ctx.plugin.truncated_frame = fmt("%s%s", (kong.ctx.plugin.truncated_frame or ""), dat)
|
|
end
|
|
|
|
break -- stop parsing immediately, server has done something wrong
|
|
end
|
|
|
|
-- test for abnormal start-of-frame (truncation tail)
|
|
if kong and kong.ctx.plugin.truncated_frame then
|
|
-- this is the tail of a previous incomplete chunk
|
|
ngx.log(ngx.DEBUG, "[ai-proxy] truncated sse frame tail")
|
|
dat = fmt("%s%s", kong.ctx.plugin.truncated_frame, dat)
|
|
kong.ctx.plugin.truncated_frame = nil
|
|
end
|
|
|
|
local s1, _ = str_find(dat, ":") -- find where the cut point is
|
|
|
|
if s1 and s1 ~= 1 then
|
|
local field = str_sub(dat, 1, s1-1) -- returns "data" from data: hello world
|
|
local value = str_ltrim(str_sub(dat, s1+1)) -- returns "hello world" from data: hello world
|
|
|
|
-- for now not checking if the value is already been set
|
|
if field == "event" then struct.event = value
|
|
elseif field == "id" then struct.id = value
|
|
elseif field == "data" then struct.data = value
|
|
end -- if
|
|
end -- if
|
|
end
|
|
end
|
|
|
|
return events
|
|
end
|
|
|
|
function _M.to_ollama(request_table, model)
|
|
local input = {}
|
|
|
|
if request_table.prompt and request_table.messages then
|
|
return kong.response.exit(400, "cannot run raw 'prompt' and chat history 'messages' requests at the same time - refer to schema")
|
|
|
|
elseif request_table.messages then
|
|
input.messages = request_table.messages
|
|
|
|
elseif request_table.prompt then
|
|
input.prompt = request_table.prompt
|
|
|
|
end
|
|
|
|
-- common parameters
|
|
input.stream = request_table.stream or false -- for future capability
|
|
input.model = model.name or request_table.name
|
|
|
|
-- handle function calling translation from Ollama format
|
|
input.tools = request_table.tools
|
|
input.tool_choice = request_table.tool_choice
|
|
|
|
if model.options then
|
|
input.options = {}
|
|
|
|
input.options.num_predict = request_table.max_tokens
|
|
input.options.temperature = request_table.temperature
|
|
input.options.top_p = request_table.top_p
|
|
input.options.top_k = request_table.top_k
|
|
end
|
|
|
|
return input, "application/json", nil
|
|
end
|
|
|
|
function _M.from_ollama(response_string, model_info, route_type)
|
|
local output, err, _, analytics
|
|
|
|
if route_type == "stream/llm/v1/chat" then
|
|
local response_table, err = cjson.decode(response_string.data)
|
|
if err then
|
|
return nil, "failed to decode ollama response"
|
|
end
|
|
|
|
output, _, analytics = handle_stream_event(response_table, model_info, route_type)
|
|
|
|
elseif route_type == "stream/llm/v1/completions" then
|
|
local response_table, err = cjson.decode(response_string.data)
|
|
if err then
|
|
return nil, "failed to decode ollama response"
|
|
end
|
|
|
|
output, _, analytics = handle_stream_event(response_table, model_info, route_type)
|
|
|
|
else
|
|
local response_table, err = cjson.decode(response_string)
|
|
if err then
|
|
return nil, "failed to decode ollama response"
|
|
end
|
|
|
|
-- there is no direct field indicating STOP reason, so calculate it manually
|
|
local stop_length = (model_info.options and model_info.options.max_tokens) or -1
|
|
local stop_reason = "stop"
|
|
if response_table.eval_count and response_table.eval_count == stop_length then
|
|
stop_reason = "length"
|
|
end
|
|
|
|
output = {}
|
|
|
|
-- common fields
|
|
output.model = response_table.model
|
|
output.created = response_table.created_at
|
|
|
|
-- analytics
|
|
output.usage = {
|
|
completion_tokens = response_table.eval_count or 0,
|
|
prompt_tokens = response_table.prompt_eval_count or 0,
|
|
total_tokens = (response_table.eval_count or 0) +
|
|
(response_table.prompt_eval_count or 0),
|
|
}
|
|
|
|
if route_type == "llm/v1/chat" then
|
|
output.object = "chat.completion"
|
|
output.choices = {
|
|
{
|
|
finish_reason = response_table.finish_reason or stop_reason,
|
|
index = 0,
|
|
message = response_table.message,
|
|
}
|
|
}
|
|
|
|
elseif route_type == "llm/v1/completions" then
|
|
output.object = "text_completion"
|
|
output.choices = {
|
|
{
|
|
index = 0,
|
|
text = response_table.response,
|
|
}
|
|
}
|
|
|
|
else
|
|
return nil, "no ollama-format transformer for response type " .. route_type
|
|
|
|
end
|
|
end
|
|
|
|
if output and output ~= _M._CONST.SSE_TERMINATOR then
|
|
output, err = cjson.encode(output)
|
|
end
|
|
|
|
-- err maybe be nil from successful decode above
|
|
return output, err, analytics
|
|
end
|
|
|
|
function _M.conf_from_request(kong_request, source, key)
|
|
if source == "uri_captures" then
|
|
return kong_request.get_uri_captures().named[key]
|
|
elseif source == "headers" then
|
|
return kong_request.get_header(key)
|
|
elseif source == "query_params" then
|
|
return kong_request.get_query_arg(key)
|
|
else
|
|
return nil, "source '" .. source .. "' is not supported"
|
|
end
|
|
end
|
|
|
|
|
|
function _M.merge_model_options(kong_request, conf_m)
|
|
if not conf_m then
|
|
return conf_m
|
|
end
|
|
|
|
local err
|
|
local new_conf_m = {}
|
|
|
|
-- recursively apply template
|
|
for k, v in pairs(conf_m) do
|
|
if type(v) == "table" then
|
|
new_conf_m[k], err = _M.merge_model_options(kong_request, v)
|
|
if err then
|
|
return nil, err
|
|
end
|
|
|
|
elseif type(v) ~= "string" then
|
|
new_conf_m[k] = v
|
|
|
|
else -- string values
|
|
local tmpl_start, tmpl_end = str_find(v or "", '%$%((.-)%)')
|
|
if tmpl_start then
|
|
local tmpl = str_sub(v, tmpl_start+2, tmpl_end-1) -- strip surrounding $( and )
|
|
local splitted = split(tmpl, '.')
|
|
if #splitted ~= 2 then
|
|
return nil, "cannot parse expression for field '" .. v .. "'"
|
|
end
|
|
local evaluated, err = _M.conf_from_request(kong_request, splitted[1], splitted[2])
|
|
if err then
|
|
return nil, err
|
|
end
|
|
if not evaluated then
|
|
return nil, splitted[1] .. " key " .. splitted[2] .. " was not provided"
|
|
end
|
|
-- replace place holder with evaluated
|
|
new_conf_m[k] = str_sub(v, 1, tmpl_start - 1) .. evaluated .. str_sub(v, tmpl_end + 1)
|
|
else -- not a tmplate, just copy
|
|
new_conf_m[k] = v
|
|
end
|
|
end
|
|
end
|
|
|
|
return new_conf_m
|
|
end
|
|
|
|
|
|
-- used by llm/init.lua:ai_introspect_body only (transformer plugins)
|
|
function _M.pre_request(conf, request_table)
|
|
-- process form/json body auth information
|
|
local auth_param_name = conf.auth and conf.auth.param_name
|
|
local auth_param_value = conf.auth and conf.auth.param_value
|
|
local auth_param_location = conf.auth and conf.auth.param_location
|
|
|
|
if auth_param_name and auth_param_value and auth_param_location == "body" and request_table then
|
|
if request_table[auth_param_name] == nil or not conf.auth.allow_override then
|
|
request_table[auth_param_name] = auth_param_value
|
|
end
|
|
end
|
|
|
|
-- retrieve the plugin name
|
|
local plugin_name = conf.__key__:match('plugins:(.-):')
|
|
if not plugin_name or plugin_name == "" then
|
|
return nil, "no plugin name is being passed by the plugin"
|
|
end
|
|
|
|
-- if enabled AND request type is compatible, capture the input for analytics
|
|
if conf.logging and conf.logging.log_payloads then
|
|
kong.log.set_serialize_value(fmt("ai.%s.%s.%s", plugin_name, log_entry_keys.PAYLOAD_CONTAINER, log_entry_keys.REQUEST_BODY), kong.request.get_raw_body())
|
|
end
|
|
|
|
local start_time_key = "ai_request_start_time_" .. plugin_name
|
|
kong.ctx.plugin[start_time_key] = ngx.now()
|
|
|
|
return true, nil
|
|
end
|
|
|
|
local function get_plugin_analytics_container(plugin_name)
|
|
-- check if we already have analytics in this context
|
|
local request_analytics = kong.ctx.shared.llm_request_analytics
|
|
if not request_analytics then
|
|
request_analytics = {}
|
|
kong.ctx.shared.llm_request_analytics = request_analytics
|
|
end
|
|
|
|
request_analytics[plugin_name] = request_analytics[plugin_name] or {
|
|
[log_entry_keys.META_CONTAINER] = {},
|
|
[log_entry_keys.USAGE_CONTAINER] = {},
|
|
[log_entry_keys.CACHE_CONTAINER] = {},
|
|
}
|
|
|
|
return request_analytics[plugin_name]
|
|
end
|
|
|
|
-- used by llm/init.lua:ai_introspect_body only (transformer plugins)
|
|
function _M.post_request(conf, response_object)
|
|
local body_string, err
|
|
|
|
if not response_object then
|
|
return
|
|
end
|
|
|
|
if type(response_object) == "string" then
|
|
-- set raw string body first, then decode
|
|
body_string = response_object
|
|
|
|
-- unpack the original response object for getting token and meta info
|
|
response_object, err = cjson.decode(response_object)
|
|
if err then
|
|
return nil, "failed to decode LLM response from JSON"
|
|
end
|
|
else
|
|
-- this has come from another AI subsystem, is already formatted, and contains "response" field
|
|
body_string = response_object.response or "ERROR__NOT_SET"
|
|
end
|
|
|
|
local plugin_name = conf.__key__:match('plugins:(.-):')
|
|
if not plugin_name or plugin_name == "" then
|
|
return nil, "no plugin name is being passed by the plugin"
|
|
end
|
|
|
|
-- create or load exsiting a analytics structure for this plugin
|
|
local request_analytics_plugin = get_plugin_analytics_container(plugin_name)
|
|
|
|
-- Set meta data
|
|
local meta_container = request_analytics_plugin[log_entry_keys.META_CONTAINER]
|
|
meta_container[log_entry_keys.PLUGIN_ID] = conf.__plugin_id
|
|
meta_container[log_entry_keys.PROVIDER_NAME] = conf.model.provider
|
|
local model_t = ai_plugin_ctx.get_request_model_table_inuse()
|
|
meta_container[log_entry_keys.REQUEST_MODEL] = model_t and model_t.name or "UNSPECIFIED"
|
|
meta_container[log_entry_keys.RESPONSE_MODEL] = response_object.model or conf.model.name
|
|
|
|
-- Set the llm latency meta, and time per token usage
|
|
local start_time_key = "ai_request_start_time_" .. plugin_name
|
|
if kong.ctx.plugin[start_time_key] then
|
|
local llm_latency = math.floor((ngx.now() - kong.ctx.plugin[start_time_key]) * 1000)
|
|
meta_container[log_entry_keys.LLM_LATENCY] = llm_latency
|
|
|
|
if response_object.usage and response_object.usage.completion_tokens then
|
|
local time_per_token = 0
|
|
if response_object.usage.completion_tokens > 0 then
|
|
time_per_token = math.floor(llm_latency / response_object.usage.completion_tokens)
|
|
end
|
|
request_analytics_plugin[log_entry_keys.USAGE_CONTAINER][log_entry_keys.TIME_PER_TOKEN] = time_per_token
|
|
end
|
|
end
|
|
|
|
-- set extra per-provider meta
|
|
if kong.ctx.plugin.ai_extra_meta and type(kong.ctx.plugin.ai_extra_meta) == "table" then
|
|
for k, v in pairs(kong.ctx.plugin.ai_extra_meta) do
|
|
request_analytics_plugin[log_entry_keys.META_CONTAINER][k] = v
|
|
end
|
|
end
|
|
|
|
-- Capture openai-format usage stats from the transformed response body
|
|
if response_object.usage then
|
|
if response_object.usage.prompt_tokens then
|
|
request_analytics_plugin[log_entry_keys.USAGE_CONTAINER][log_entry_keys.PROMPT_TOKENS] = response_object.usage.prompt_tokens
|
|
end
|
|
if response_object.usage.completion_tokens then
|
|
request_analytics_plugin[log_entry_keys.USAGE_CONTAINER][log_entry_keys.COMPLETION_TOKENS] = response_object.usage.completion_tokens
|
|
end
|
|
if response_object.usage.total_tokens then
|
|
request_analytics_plugin[log_entry_keys.USAGE_CONTAINER][log_entry_keys.TOTAL_TOKENS] = response_object.usage.total_tokens
|
|
end
|
|
|
|
ai_plugin_o11y.metrics_set("llm_prompt_tokens_count", response_object.usage.prompt_tokens)
|
|
ai_plugin_o11y.metrics_set("llm_completion_tokens_count", response_object.usage.completion_tokens)
|
|
|
|
if response_object.usage.prompt_tokens and response_object.usage.completion_tokens and
|
|
conf.model.options and conf.model.options.input_cost and conf.model.options.output_cost then
|
|
local cost = (response_object.usage.prompt_tokens * conf.model.options.input_cost +
|
|
response_object.usage.completion_tokens * conf.model.options.output_cost) / 1000000 -- 1 million
|
|
request_analytics_plugin[log_entry_keys.USAGE_CONTAINER][log_entry_keys.COST] = cost
|
|
ai_plugin_o11y.metrics_set("llm_usage_cost", cost)
|
|
end
|
|
|
|
else
|
|
-- log tokens response for reports and billing
|
|
local response_tokens, err = _M.calculate_cost(response_object, {}, 1.0)
|
|
if err then
|
|
kong.log.warn("failed calculating cost for response tokens: ", err)
|
|
response_tokens = 0
|
|
end
|
|
|
|
ai_plugin_o11y.metrics_set("llm_completion_tokens_count", response_tokens)
|
|
end
|
|
|
|
-- Log response body if logging payloads is enabled
|
|
if conf.logging and conf.logging.log_payloads then
|
|
kong.log.set_serialize_value(fmt("ai.%s.%s.%s", plugin_name, log_entry_keys.PAYLOAD_CONTAINER, log_entry_keys.RESPONSE_BODY), body_string)
|
|
end
|
|
|
|
-- Update context with changed values
|
|
request_analytics_plugin[log_entry_keys.PAYLOAD_CONTAINER] = {
|
|
[log_entry_keys.RESPONSE_BODY] = body_string,
|
|
}
|
|
|
|
if conf.logging and conf.logging.log_statistics then
|
|
-- Log meta data
|
|
kong.log.set_serialize_value(fmt("ai.%s.%s", plugin_name, log_entry_keys.META_CONTAINER),
|
|
request_analytics_plugin[log_entry_keys.META_CONTAINER])
|
|
|
|
-- Log usage data
|
|
kong.log.set_serialize_value(fmt("ai.%s.%s", plugin_name, log_entry_keys.USAGE_CONTAINER),
|
|
request_analytics_plugin[log_entry_keys.USAGE_CONTAINER])
|
|
|
|
-- Log cache data
|
|
kong.log.set_serialize_value(fmt("ai.%s.%s", plugin_name, log_entry_keys.CACHE_CONTAINER),
|
|
request_analytics_plugin[log_entry_keys.CACHE_CONTAINER])
|
|
end
|
|
|
|
return true
|
|
end
|
|
|
|
|
|
function _M.http_request(url, body, method, headers, http_opts, buffered)
|
|
local httpc = http.new()
|
|
|
|
if http_opts.http_timeout then
|
|
httpc:set_timeouts(http_opts.http_timeout)
|
|
end
|
|
|
|
if http_opts.proxy_opts then
|
|
httpc:set_proxy_options(http_opts.proxy_opts)
|
|
end
|
|
|
|
local parsed = parse_url(url)
|
|
|
|
if buffered then
|
|
local ok, err, _ = httpc:connect({
|
|
scheme = parsed.scheme,
|
|
host = parsed.host,
|
|
port = parsed.port or 443, -- this always fails. experience.
|
|
ssl_server_name = parsed.host,
|
|
ssl_verify = http_opts.https_verify,
|
|
})
|
|
if not ok then
|
|
return nil, err
|
|
end
|
|
|
|
local res, err = httpc:request({
|
|
path = parsed.path or "/",
|
|
query = parsed.query,
|
|
method = method,
|
|
headers = headers,
|
|
body = body,
|
|
})
|
|
if not res then
|
|
return nil, "connection failed: " .. err
|
|
end
|
|
|
|
return res, nil, httpc
|
|
else
|
|
-- 'single-shot'
|
|
local res, err = httpc:request_uri(
|
|
url,
|
|
{
|
|
method = method,
|
|
body = body,
|
|
headers = headers,
|
|
ssl_verify = http_opts.https_verify,
|
|
})
|
|
if not res then
|
|
return nil, "request failed: " .. err
|
|
end
|
|
|
|
return res, nil, nil
|
|
end
|
|
end
|
|
|
|
-- Function to count the number of words in a string
|
|
local function count_words(any)
|
|
local count = 0
|
|
if type(any) == "string" then
|
|
for _ in any:gmatch("%S+") do
|
|
count = count + 1
|
|
end
|
|
elseif type(any) == "table" then -- is multi-modal input
|
|
for _, item in ipairs(any) do
|
|
if item.type == "text" and item.text then
|
|
for _ in (item.text):gmatch("%S+") do
|
|
count = count + 1
|
|
end
|
|
end
|
|
end
|
|
end
|
|
return count
|
|
end
|
|
|
|
-- Function to count the number of words or tokens based on the content type
|
|
local function count_prompt(content, tokens_factor)
|
|
local count = 0
|
|
|
|
if type(content) == "string" then
|
|
count = count_words(content) * tokens_factor
|
|
elseif type(content) == "table" then
|
|
for _, item in ipairs(content) do
|
|
if type(item) == "string" then
|
|
count = count + (count_words(item) * tokens_factor)
|
|
elseif type(item) == "number" then
|
|
count = count + 1
|
|
elseif type(item) == "table" then
|
|
for _2, item2 in ipairs(item) do
|
|
if type(item2) == "number" then
|
|
count = count + 1
|
|
else
|
|
return nil, "Invalid request format"
|
|
end
|
|
end
|
|
else
|
|
return nil, "Invalid request format"
|
|
end
|
|
end
|
|
else
|
|
return nil, "Invalid request format"
|
|
end
|
|
return count, nil
|
|
end
|
|
|
|
function _M.calculate_cost(query_body, tokens_models, tokens_factor)
|
|
local query_cost = 0
|
|
local err
|
|
|
|
if not query_body then
|
|
return nil, "cannot calculate tokens on empty request"
|
|
end
|
|
|
|
if query_body.choices then
|
|
-- Calculate the cost based on the content type
|
|
for _, choice in ipairs(query_body.choices) do
|
|
if choice.message and choice.message.content then
|
|
query_cost = query_cost + (count_words(choice.message.content) * tokens_factor)
|
|
elseif choice.text then
|
|
query_cost = query_cost + (count_words(choice.text) * tokens_factor)
|
|
end
|
|
end
|
|
elseif query_body.messages then
|
|
-- Calculate the cost based on the content type
|
|
for _, message in ipairs(query_body.messages) do
|
|
query_cost = query_cost + (count_words(message.content) * tokens_factor)
|
|
end
|
|
elseif query_body.prompt then
|
|
-- Calculate the cost based on the content type
|
|
query_cost, err = count_prompt(query_body.prompt, tokens_factor)
|
|
if err then
|
|
return nil, err
|
|
end
|
|
end
|
|
|
|
-- Round the total cost quantified
|
|
query_cost = math.floor(query_cost + 0.5)
|
|
|
|
return query_cost, nil
|
|
end
|
|
|
|
function _M.override_upstream_url(parsed_url, conf, model)
|
|
assert(model, "missing model parameter")
|
|
|
|
if conf.route_type == "preserve" then
|
|
-- if `upstream_path` was set, already processes before,
|
|
-- for some provider, like azure and huggingface, the specific prefix need to prepended to the path.
|
|
if model.options and model.options.upstream_path then
|
|
return
|
|
end
|
|
-- why?
|
|
parsed_url.path = kong.request.get_path()
|
|
end
|
|
end
|
|
|
|
-- for unit tests
|
|
if _G.TEST then
|
|
_M._count_words = count_words
|
|
_M._frame_to_events = _M.frame_to_events
|
|
_M._json_array_iterator = json_array_iterator
|
|
_M._set_kong = function(this_kong)
|
|
_G.kong = this_kong
|
|
end
|
|
_M._get_kong = function()
|
|
return kong
|
|
end
|
|
end
|
|
|
|
return _M
|