kong--kong
691 行
19 KiB
Lua
691 行
19 KiB
Lua
local _M = {}
|
|
|
|
-- imports
|
|
local cjson = require("cjson.safe")
|
|
local fmt = string.format
|
|
local ai_shared = require("kong.llm.drivers.shared")
|
|
local socket_url = require("socket.url")
|
|
local string_gsub = string.gsub
|
|
local table_insert = table.insert
|
|
local signer = require("resty.aws.request.sign")
|
|
local ai_plugin_ctx = require("kong.llm.plugin.ctx")
|
|
--
|
|
|
|
-- globals
|
|
local DRIVER_NAME = "bedrock"
|
|
local get_global_ctx, _ = ai_plugin_ctx.get_global_accessors(DRIVER_NAME)
|
|
--
|
|
|
|
local _OPENAI_ROLE_MAPPING = {
|
|
["system"] = "assistant",
|
|
["user"] = "user",
|
|
["assistant"] = "assistant",
|
|
["tool"] = "user",
|
|
}
|
|
|
|
local _OPENAI_STOP_REASON_MAPPING = {
|
|
["max_tokens"] = "length",
|
|
["end_turn"] = "stop",
|
|
["tool_use"] = "tool_calls",
|
|
["guardrail_intervened"] = "guardrail_intervened",
|
|
}
|
|
|
|
_M.bedrock_unsupported_system_role_patterns = {
|
|
"amazon.titan.-.*",
|
|
"cohere.command.-text.-.*",
|
|
"cohere.command.-light.-text.-.*",
|
|
"mistral.mistral.-7b.-instruct.-.*",
|
|
"mistral.mixtral.-8x7b.-instruct.-.*",
|
|
}
|
|
|
|
local function to_bedrock_generation_config(request_table)
|
|
return {
|
|
["maxTokens"] = request_table.max_tokens,
|
|
["stopSequences"] = request_table.stop,
|
|
["temperature"] = request_table.temperature,
|
|
["topP"] = request_table.top_p,
|
|
}
|
|
end
|
|
|
|
local function to_bedrock_guardrail_config(guardrail_config)
|
|
return guardrail_config -- may be nil; this is handled
|
|
end
|
|
|
|
-- this is a placeholder and is archaic now,
|
|
-- leave it in for backwards compatibility
|
|
local function to_additional_request_fields(request_table)
|
|
return {
|
|
request_table.bedrock.additionalModelRequestFields
|
|
}
|
|
end
|
|
|
|
-- this is a placeholder and is archaic now,
|
|
-- leave it in for backwards compatibility
|
|
local function to_tool_config(request_table)
|
|
return {
|
|
request_table.bedrock.toolConfig
|
|
}
|
|
end
|
|
|
|
local function to_tools(in_tools)
|
|
if not in_tools then
|
|
return nil
|
|
end
|
|
|
|
local out_tools
|
|
|
|
for i, v in ipairs(in_tools) do
|
|
if v['function'] then
|
|
out_tools = out_tools or {}
|
|
|
|
out_tools[i] = {
|
|
toolSpec = {
|
|
name = v['function'].name,
|
|
description = v['function'].description,
|
|
inputSchema = {
|
|
json = v['function'].parameters,
|
|
},
|
|
},
|
|
}
|
|
end
|
|
end
|
|
|
|
return out_tools
|
|
end
|
|
|
|
local function from_tool_call_response(content)
|
|
if not content then return nil end
|
|
|
|
local tools_used
|
|
|
|
for _, t in ipairs(content) do
|
|
if t.toolUse then
|
|
tools_used = tools_used or {}
|
|
|
|
local arguments
|
|
if t.toolUse['input'] and next(t.toolUse['input']) then
|
|
arguments = cjson.encode(t.toolUse['input'])
|
|
end
|
|
|
|
tools_used[#tools_used+1] = {
|
|
-- set explicit numbering to ensure ordering in later modifications
|
|
['function'] = {
|
|
arguments = arguments,
|
|
name = t.toolUse.name,
|
|
},
|
|
id = t.toolUse.toolUseId,
|
|
type = "function",
|
|
}
|
|
end
|
|
end
|
|
|
|
return tools_used
|
|
end
|
|
|
|
local function handle_stream_event(event_t, model_info, route_type)
|
|
local new_event, metadata
|
|
|
|
if (not event_t) or (not event_t.data) then
|
|
return "", nil, nil
|
|
end
|
|
|
|
-- decode and determine the event type
|
|
local event = cjson.decode(event_t.data)
|
|
local event_type = event and event.headers and event.headers[":event-type"]
|
|
|
|
if not event_type then
|
|
return "", nil, nil
|
|
end
|
|
|
|
local body = event.body and cjson.decode(event.body)
|
|
|
|
if not body then
|
|
return "", nil, nil
|
|
end
|
|
|
|
if event_type == "messageStart" then
|
|
new_event = {
|
|
choices = {
|
|
[1] = {
|
|
delta = {
|
|
content = "",
|
|
role = body.role,
|
|
},
|
|
index = 0,
|
|
logprobs = cjson.null,
|
|
},
|
|
},
|
|
model = model_info.name,
|
|
object = "chat.completion.chunk",
|
|
system_fingerprint = cjson.null,
|
|
}
|
|
|
|
elseif event_type == "contentBlockStart" then
|
|
-- check for tool-usage entrypoint
|
|
if body.start and body.start.toolUse then
|
|
local tool_name = body.start.toolUse.name
|
|
local tool_id = body.start.toolUse.toolUseId
|
|
|
|
new_event = {
|
|
choices = {
|
|
[1] = {
|
|
delta = {
|
|
tool_calls = {
|
|
{
|
|
index = body.contentBlockIndex,
|
|
id = tool_id,
|
|
['function'] = {
|
|
name = tool_name,
|
|
arguments = "",
|
|
},
|
|
}
|
|
}
|
|
},
|
|
index = 0,
|
|
logprobs = cjson.null,
|
|
},
|
|
},
|
|
model = model_info.name,
|
|
object = "chat.completion.chunk",
|
|
system_fingerprint = cjson.null,
|
|
}
|
|
end
|
|
|
|
elseif event_type == "contentBlockDelta" then
|
|
-- check for async streamed tool parameters
|
|
if body.delta and body.delta.toolUse then
|
|
new_event = {
|
|
choices = {
|
|
[1] = {
|
|
delta = {
|
|
tool_calls = {
|
|
{
|
|
index = body.contentBlockIndex,
|
|
['function'] = {
|
|
arguments = body.delta.toolUse.input,
|
|
},
|
|
}
|
|
}
|
|
},
|
|
index = 0,
|
|
logprobs = cjson.null,
|
|
},
|
|
},
|
|
model = model_info.name,
|
|
object = "chat.completion.chunk",
|
|
system_fingerprint = cjson.null,
|
|
}
|
|
|
|
else
|
|
new_event = {
|
|
choices = {
|
|
[1] = {
|
|
delta = {
|
|
content = (body.delta
|
|
and body.delta.text)
|
|
or "",
|
|
},
|
|
index = 0,
|
|
logprobs = cjson.null,
|
|
},
|
|
},
|
|
model = model_info.name,
|
|
object = "chat.completion.chunk",
|
|
system_fingerprint = cjson.null,
|
|
}
|
|
end
|
|
|
|
elseif event_type == "messageStop" then
|
|
new_event = {
|
|
choices = {
|
|
[1] = {
|
|
delta = {},
|
|
index = 0,
|
|
finish_reason = _OPENAI_STOP_REASON_MAPPING[body.stopReason] or "stop",
|
|
logprobs = cjson.null,
|
|
},
|
|
},
|
|
model = model_info.name,
|
|
object = "chat.completion.chunk",
|
|
}
|
|
|
|
elseif event_type == "metadata" then
|
|
metadata = {
|
|
prompt_tokens = body.usage and body.usage.inputTokens or 0,
|
|
completion_tokens = body.usage and body.usage.outputTokens or 0,
|
|
}
|
|
|
|
new_event = ai_shared._CONST.SSE_TERMINATOR
|
|
|
|
-- "contentBlockStop" is absent because it is not used for anything here
|
|
end
|
|
|
|
if new_event then
|
|
if new_event ~= ai_shared._CONST.SSE_TERMINATOR then
|
|
new_event = cjson.encode(new_event)
|
|
end
|
|
|
|
return new_event, nil, metadata
|
|
else
|
|
return nil, nil, metadata -- caller code will handle "unrecognised" event types
|
|
end
|
|
end
|
|
|
|
local function to_bedrock_chat_openai(request_table, model_info, route_type)
|
|
if not request_table then
|
|
local err = "empty request table received for transformation"
|
|
ngx.log(ngx.ERR, "[bedrock] ", err)
|
|
return nil, nil, err
|
|
end
|
|
|
|
local new_r = {}
|
|
|
|
-- anthropic models support variable versions, just like self-hosted
|
|
new_r.anthropic_version = model_info.options and model_info.options.anthropic_version
|
|
or "bedrock-2023-05-31"
|
|
|
|
if request_table.messages and #request_table.messages > 0 then
|
|
local system_prompts = {}
|
|
|
|
for i, v in ipairs(request_table.messages) do
|
|
-- for 'system', we just concat them all into one Bedrock instruction
|
|
if v.role and v.role == "system" then
|
|
system_prompts[#system_prompts+1] = { text = v.content }
|
|
|
|
elseif v.role and v.role == "tool" then
|
|
local tool_literal_content
|
|
local tool_execution_content, err = cjson.decode(v.content)
|
|
if err then
|
|
return nil, nil, "failed to decode function response arguments, not JSON format"
|
|
end
|
|
|
|
if type(tool_execution_content) == "table" then
|
|
tool_literal_content = {
|
|
json = tool_execution_content
|
|
}
|
|
|
|
else
|
|
tool_literal_content = {
|
|
json = {
|
|
result = tool_execution_content
|
|
}
|
|
}
|
|
end
|
|
|
|
local content = {
|
|
{
|
|
toolResult = {
|
|
toolUseId = v.tool_call_id,
|
|
content = {
|
|
tool_literal_content
|
|
},
|
|
status = v.status,
|
|
},
|
|
},
|
|
}
|
|
|
|
new_r.messages = new_r.messages or {}
|
|
table_insert(new_r.messages, {
|
|
role = _OPENAI_ROLE_MAPPING[v.role or "user"], -- default to 'user'
|
|
content = content,
|
|
})
|
|
|
|
else
|
|
local content
|
|
if type(v.content) == "table" then
|
|
content = v.content
|
|
|
|
elseif v.tool_calls and (type(v.tool_calls) == "table") then
|
|
for k, tool in ipairs(v.tool_calls) do
|
|
local inputs, err = cjson.decode(tool['function'].arguments)
|
|
if err then
|
|
return nil, nil, "failed to decode function response arguments from assistant's message, not JSON format"
|
|
end
|
|
|
|
content = {
|
|
{
|
|
toolUse = {
|
|
toolUseId = tool.id,
|
|
name = tool['function'].name,
|
|
input = inputs,
|
|
},
|
|
},
|
|
}
|
|
end
|
|
|
|
else
|
|
content = {
|
|
{
|
|
text = v.content or ""
|
|
},
|
|
}
|
|
end
|
|
|
|
-- for any other role, just construct the chat history as 'parts.text' type
|
|
new_r.messages = new_r.messages or {}
|
|
table_insert(new_r.messages, {
|
|
role = _OPENAI_ROLE_MAPPING[v.role or "user"], -- default to 'user'
|
|
content = content,
|
|
})
|
|
end
|
|
end
|
|
|
|
-- only works for some models
|
|
if #system_prompts > 0 then
|
|
for _, p in ipairs(_M.bedrock_unsupported_system_role_patterns) do
|
|
if model_info.name:find(p) then
|
|
return nil, nil, "system prompts are unsupported for model '" .. model_info.name
|
|
end
|
|
end
|
|
|
|
new_r.system = system_prompts
|
|
end
|
|
end
|
|
|
|
new_r.inferenceConfig = to_bedrock_generation_config(request_table)
|
|
new_r.guardrailConfig = to_bedrock_guardrail_config(request_table.guardrailConfig)
|
|
|
|
-- backwards compatibility
|
|
new_r.toolConfig = request_table.bedrock
|
|
and request_table.bedrock.toolConfig
|
|
and to_tool_config(request_table)
|
|
|
|
if request_table.tools
|
|
and type(request_table.tools) == "table"
|
|
and #request_table.tools > 0 then
|
|
|
|
new_r.toolConfig = new_r.toolConfig or {}
|
|
new_r.toolConfig.tools = to_tools(request_table.tools)
|
|
end
|
|
|
|
new_r.additionalModelRequestFields = request_table.bedrock
|
|
and request_table.bedrock.additionalModelRequestFields
|
|
and to_additional_request_fields(request_table)
|
|
|
|
return new_r, "application/json", nil
|
|
end
|
|
|
|
local function from_bedrock_chat_openai(response, model_info, route_type)
|
|
local response, err = cjson.decode(response)
|
|
|
|
if err then
|
|
local err_client = "failed to decode response from Bedrock"
|
|
ngx.log(ngx.ERR, fmt("[bedrock] %s: %s", err_client, err))
|
|
return nil, err_client
|
|
end
|
|
|
|
local client_response = {}
|
|
client_response.choices = {}
|
|
|
|
if response.output
|
|
and response.output.message
|
|
and response.output.message.content
|
|
and #response.output.message.content > 0 then
|
|
|
|
client_response.choices[1] = {
|
|
index = 0,
|
|
message = {
|
|
role = "assistant",
|
|
content = response.output.message.content[1].text, -- may be nil
|
|
tool_calls = from_tool_call_response(response.output.message.content),
|
|
},
|
|
finish_reason = _OPENAI_STOP_REASON_MAPPING[response.stopReason] or "stop",
|
|
}
|
|
client_response.object = "chat.completion"
|
|
client_response.model = model_info.name
|
|
|
|
else -- probably a server fault or other unexpected response
|
|
local err = "no generation candidates received from Bedrock, or max_tokens too short"
|
|
ngx.log(ngx.ERR, "[bedrock] ", err)
|
|
return nil, err
|
|
end
|
|
|
|
-- process analytics
|
|
if response.usage then
|
|
client_response.usage = {
|
|
prompt_tokens = response.usage.inputTokens,
|
|
completion_tokens = response.usage.outputTokens,
|
|
total_tokens = response.usage.totalTokens,
|
|
}
|
|
end
|
|
|
|
client_response.trace = response.trace -- may be nil, **do not** map to cjson.null
|
|
|
|
return cjson.encode(client_response)
|
|
end
|
|
|
|
local transformers_to = {
|
|
["llm/v1/chat"] = to_bedrock_chat_openai,
|
|
}
|
|
|
|
local transformers_from = {
|
|
["llm/v1/chat"] = from_bedrock_chat_openai,
|
|
["stream/llm/v1/chat"] = handle_stream_event,
|
|
}
|
|
|
|
function _M.from_format(response_string, model_info, route_type)
|
|
ngx.log(ngx.DEBUG, "converting from ", model_info.provider, "://", route_type, " type to kong")
|
|
|
|
-- MUST return a string, to set as the response body
|
|
if not transformers_from[route_type] then
|
|
return nil, fmt("no transformer available from format %s://%s", model_info.provider, route_type)
|
|
end
|
|
|
|
local ok, response_string, err, metadata = pcall(transformers_from[route_type], response_string, model_info, route_type)
|
|
if not ok then
|
|
err = response_string
|
|
end
|
|
if err then
|
|
return nil, fmt("transformation failed from type %s://%s: %s",
|
|
model_info.provider,
|
|
route_type,
|
|
err or "unexpected_error"
|
|
)
|
|
end
|
|
|
|
return response_string, nil, metadata
|
|
end
|
|
|
|
function _M.to_format(request_table, model_info, route_type)
|
|
ngx.log(ngx.DEBUG, "converting from kong type to ", model_info.provider, "/", route_type)
|
|
|
|
if route_type == "preserve" then
|
|
-- do nothing
|
|
return request_table, nil, nil
|
|
end
|
|
|
|
if not transformers_to[route_type] then
|
|
return nil, nil, fmt("no transformer for %s://%s", model_info.provider, route_type)
|
|
end
|
|
|
|
request_table = ai_shared.merge_config_defaults(request_table, model_info.options, model_info.route_type)
|
|
|
|
local ok, response_object, content_type, err = pcall(
|
|
transformers_to[route_type],
|
|
request_table,
|
|
model_info
|
|
)
|
|
if err or (not ok) then
|
|
return nil, nil, fmt("error transforming to %s://%s: %s", model_info.provider, route_type, err)
|
|
end
|
|
|
|
return response_object, content_type, nil
|
|
end
|
|
|
|
function _M.subrequest(body, conf, http_opts, return_res_table, identity_interface)
|
|
-- use shared/standard subrequest routine
|
|
local body_string, err
|
|
|
|
if type(body) == "table" then
|
|
body_string, err = cjson.encode(body)
|
|
if err then
|
|
return nil, nil, "failed to parse body to json: " .. err
|
|
end
|
|
elseif type(body) == "string" then
|
|
body_string = body
|
|
else
|
|
return nil, nil, "body must be table or string"
|
|
end
|
|
|
|
-- may be overridden
|
|
local f_url = conf.model.options and conf.model.options.upstream_url
|
|
if not f_url then -- upstream_url override is not set
|
|
local uri = fmt(ai_shared.upstream_url_format[DRIVER_NAME], identity_interface.interface.config.region)
|
|
local path = fmt(
|
|
ai_shared.operation_map[DRIVER_NAME][conf.route_type].path,
|
|
conf.model.name,
|
|
"converse")
|
|
|
|
f_url = uri ..path
|
|
end
|
|
|
|
local parsed_url = socket_url.parse(f_url)
|
|
local method = ai_shared.operation_map[DRIVER_NAME][conf.route_type].method
|
|
|
|
-- do the IAM auth and signature headers
|
|
identity_interface.interface.config.signatureVersion = "v4"
|
|
identity_interface.interface.config.endpointPrefix = "bedrock"
|
|
|
|
local r = {
|
|
headers = {},
|
|
method = method,
|
|
path = parsed_url.path,
|
|
host = parsed_url.host,
|
|
port = tonumber(parsed_url.port) or 443,
|
|
body = body_string,
|
|
}
|
|
|
|
local signature, err = signer(identity_interface.interface.config, r)
|
|
if not signature then
|
|
return nil, "failed to sign AWS request: " .. (err or "NONE")
|
|
end
|
|
|
|
local headers = {
|
|
["Accept"] = "application/json",
|
|
["Content-Type"] = "application/json",
|
|
}
|
|
headers["Authorization"] = signature.headers["Authorization"]
|
|
if signature.headers["X-Amz-Security-Token"] then
|
|
headers["X-Amz-Security-Token"] = signature.headers["X-Amz-Security-Token"]
|
|
end
|
|
if signature.headers["X-Amz-Date"] then
|
|
headers["X-Amz-Date"] = signature.headers["X-Amz-Date"]
|
|
end
|
|
|
|
local res, err, httpc = ai_shared.http_request(f_url, body_string, method, headers, http_opts, return_res_table)
|
|
if err then
|
|
return nil, nil, "request to ai service failed: " .. err
|
|
end
|
|
|
|
if return_res_table then
|
|
return res, res.status, nil, httpc
|
|
else
|
|
-- At this point, the entire request / response is complete and the connection
|
|
-- will be closed or back on the connection pool.
|
|
local status = res.status
|
|
local body = res.body
|
|
|
|
if status > 299 then
|
|
return body, res.status, "status code " .. status
|
|
end
|
|
|
|
return body, res.status, nil
|
|
end
|
|
end
|
|
|
|
function _M.header_filter_hooks(body)
|
|
-- nothing to parse in header_filter phase
|
|
end
|
|
|
|
function _M.post_request(conf)
|
|
if ai_shared.clear_response_headers[DRIVER_NAME] then
|
|
for i, v in ipairs(ai_shared.clear_response_headers[DRIVER_NAME]) do
|
|
kong.response.clear_header(v)
|
|
end
|
|
end
|
|
end
|
|
|
|
function _M.pre_request(conf, body)
|
|
-- force gzip for bedrock because brotli and others break streaming
|
|
kong.service.request.set_header("Accept-Encoding", "gzip, identity")
|
|
|
|
return true, nil
|
|
end
|
|
|
|
-- returns err or nil
|
|
function _M.configure_request(conf, aws_sdk)
|
|
local model = ai_plugin_ctx.get_request_model_table_inuse()
|
|
if not model or type(model) ~= "table" or model.provider ~= DRIVER_NAME then
|
|
return nil, "invalid model parameter"
|
|
end
|
|
|
|
local operation = get_global_ctx("stream_mode") and "converse-stream"
|
|
or "converse"
|
|
|
|
local f_url = model.options and model.options.upstream_url
|
|
if not f_url then -- upstream_url override is not set
|
|
local uri = fmt(ai_shared.upstream_url_format[DRIVER_NAME], aws_sdk.config.region)
|
|
local path = fmt(
|
|
ai_shared.operation_map[DRIVER_NAME][conf.route_type].path,
|
|
model.name,
|
|
operation)
|
|
|
|
f_url = uri ..path
|
|
end
|
|
|
|
local parsed_url = socket_url.parse(f_url)
|
|
|
|
if model.options and model.options.upstream_path then
|
|
-- upstream path override is set (or templated from request params)
|
|
parsed_url.path = model.options.upstream_path
|
|
end
|
|
|
|
-- if the path is read from a URL capture, ensure that it is valid
|
|
parsed_url.path = (parsed_url.path and string_gsub(parsed_url.path, "^/*", "/")) or "/"
|
|
|
|
ai_shared.override_upstream_url(parsed_url, conf, model)
|
|
|
|
kong.service.request.set_path(parsed_url.path)
|
|
kong.service.request.set_scheme(parsed_url.scheme)
|
|
kong.service.set_target(parsed_url.host, (tonumber(parsed_url.port) or 443))
|
|
|
|
-- do the IAM auth and signature headers
|
|
aws_sdk.config.signatureVersion = "v4"
|
|
aws_sdk.config.endpointPrefix = "bedrock"
|
|
|
|
local r = {
|
|
headers = {},
|
|
method = ai_shared.operation_map[DRIVER_NAME][conf.route_type].method,
|
|
path = parsed_url.path,
|
|
host = parsed_url.host,
|
|
port = tonumber(parsed_url.port) or 443,
|
|
body = kong.request.get_raw_body()
|
|
}
|
|
|
|
local signature, err = signer(aws_sdk.config, r)
|
|
if not signature then
|
|
return nil, "failed to sign AWS request: " .. (err or "NONE")
|
|
end
|
|
|
|
kong.service.request.set_header("Authorization", signature.headers["Authorization"])
|
|
if signature.headers["X-Amz-Security-Token"] then
|
|
kong.service.request.set_header("X-Amz-Security-Token", signature.headers["X-Amz-Security-Token"])
|
|
end
|
|
if signature.headers["X-Amz-Date"] then
|
|
kong.service.request.set_header("X-Amz-Date", signature.headers["X-Amz-Date"])
|
|
end
|
|
|
|
return true
|
|
end
|
|
|
|
|
|
if _G._TEST then
|
|
-- export locals for testing
|
|
_M._to_tools = to_tools
|
|
_M._to_bedrock_chat_openai = to_bedrock_chat_openai
|
|
_M._from_tool_call_response = from_tool_call_response
|
|
end
|
|
|
|
|
|
return _M
|