项目文件夹

文件
2026-07-13 12:32:21 +08:00

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