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

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