kong--kong
550 行
17 KiB
Lua
550 行
17 KiB
Lua
local _M = {}
|
|
|
|
-- imports
|
|
local cjson = require("cjson.safe")
|
|
local fmt = string.format
|
|
local ai_shared = require("kong.llm.drivers.shared")
|
|
local openai_driver = require("kong.llm.drivers.openai")
|
|
local socket_url = require "socket.url"
|
|
local table_new = require("table.new")
|
|
local string_gsub = string.gsub
|
|
local ai_plugin_ctx = require("kong.llm.plugin.ctx")
|
|
--
|
|
|
|
-- globals
|
|
local DRIVER_NAME = "cohere"
|
|
|
|
local _CHAT_ROLES = {
|
|
["system"] = "CHATBOT",
|
|
["assistant"] = "CHATBOT",
|
|
["user"] = "USER",
|
|
}
|
|
--
|
|
|
|
local function handle_stream_event(event_t, model_info, route_type)
|
|
local metadata
|
|
|
|
-- discard empty frames, it should either be a random new line, or comment
|
|
if (not event_t.data) or (#event_t.data < 1) then
|
|
return
|
|
end
|
|
|
|
local event, err = cjson.decode(event_t.data)
|
|
if err then
|
|
return nil, "failed to decode event frame from cohere: " .. err, nil
|
|
end
|
|
|
|
local new_event
|
|
|
|
if event.event_type == "stream-start" then
|
|
kong.ctx.plugin.ai_proxy_cohere_stream_id = event.generation_id
|
|
|
|
-- ignore the rest of this one
|
|
new_event = {
|
|
choices = {
|
|
[1] = {
|
|
delta = {
|
|
content = "",
|
|
role = "assistant",
|
|
},
|
|
index = 0,
|
|
},
|
|
},
|
|
id = event.generation_id,
|
|
model = model_info.name,
|
|
object = "chat.completion.chunk",
|
|
}
|
|
|
|
elseif event.event_type == "text-generation" then
|
|
-- this is a token
|
|
if route_type == "stream/llm/v1/chat" then
|
|
new_event = {
|
|
choices = {
|
|
[1] = {
|
|
delta = {
|
|
content = event.text or "",
|
|
},
|
|
index = 0,
|
|
finish_reason = cjson.null,
|
|
logprobs = cjson.null,
|
|
},
|
|
},
|
|
id = kong
|
|
and kong.ctx
|
|
and kong.ctx.plugin
|
|
and kong.ctx.plugin.ai_proxy_cohere_stream_id,
|
|
model = model_info.name,
|
|
object = "chat.completion.chunk",
|
|
}
|
|
|
|
elseif route_type == "stream/llm/v1/completions" then
|
|
new_event = {
|
|
choices = {
|
|
[1] = {
|
|
text = event.text or "",
|
|
index = 0,
|
|
finish_reason = cjson.null,
|
|
logprobs = cjson.null,
|
|
},
|
|
},
|
|
id = kong
|
|
and kong.ctx
|
|
and kong.ctx.plugin
|
|
and kong.ctx.plugin.ai_proxy_cohere_stream_id,
|
|
model = model_info.name,
|
|
object = "text_completion",
|
|
}
|
|
|
|
end
|
|
|
|
elseif event.event_type == "stream-end" then
|
|
-- return a metadata object, with the OpenAI termination event
|
|
new_event = ai_shared._CONST.SSE_TERMINATOR
|
|
|
|
metadata = {
|
|
completion_tokens = event.response
|
|
and event.response.meta
|
|
and event.response.meta.billed_units
|
|
and event.response.meta.billed_units.output_tokens
|
|
or
|
|
event.response
|
|
and event.response.token_count
|
|
and event.response.token_count.response_tokens
|
|
or 0,
|
|
|
|
prompt_tokens = event.response
|
|
and event.response.meta
|
|
and event.response.meta.billed_units
|
|
and event.response.meta.billed_units.input_tokens
|
|
or
|
|
event.response
|
|
and event.response.token_count
|
|
and event.token_count.prompt_tokens
|
|
or 0,
|
|
}
|
|
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 handle_json_inference_event(request_table, model)
|
|
request_table.temperature = request_table.temperature
|
|
request_table.max_tokens = request_table.max_tokens
|
|
|
|
request_table.p = request_table.top_p
|
|
request_table.k = request_table.top_k
|
|
|
|
request_table.top_p = nil
|
|
request_table.top_k = nil
|
|
|
|
request_table.model = model.name or request_table.model
|
|
request_table.stream = request_table.stream or false -- explicitly set this
|
|
|
|
if request_table.prompt and request_table.messages then
|
|
return kong.response.exit(400, "cannot run a 'prompt' and a history of 'messages' at the same time - refer to schema")
|
|
|
|
elseif request_table.messages then
|
|
-- we have to move all BUT THE LAST message into "chat_history" array
|
|
-- and move the LAST message (from 'user') into "message" string
|
|
if #request_table.messages > 1 then
|
|
local chat_history = table_new(#request_table.messages - 1, 0)
|
|
for i, v in ipairs(request_table.messages) do
|
|
-- if this is the last message prompt, don't add to history
|
|
if i < #request_table.messages then
|
|
local role
|
|
if v.role == "assistant" or v.role == _CHAT_ROLES.assistant then
|
|
role = _CHAT_ROLES.assistant
|
|
else
|
|
role = _CHAT_ROLES.user
|
|
end
|
|
|
|
chat_history[i] = {
|
|
role = role,
|
|
message = v.content,
|
|
}
|
|
end
|
|
end
|
|
|
|
request_table.chat_history = chat_history
|
|
end
|
|
|
|
request_table.message = request_table.messages[#request_table.messages].content
|
|
request_table.messages = nil
|
|
|
|
elseif request_table.prompt then
|
|
request_table.prompt = request_table.prompt
|
|
request_table.messages = nil
|
|
request_table.message = nil
|
|
end
|
|
|
|
return request_table, "application/json", nil
|
|
end
|
|
|
|
local transformers_to = {
|
|
["llm/v1/chat"] = handle_json_inference_event,
|
|
["llm/v1/completions"] = handle_json_inference_event,
|
|
}
|
|
|
|
local transformers_from = {
|
|
["llm/v1/chat"] = function(response_string, model_info)
|
|
local response_table, err = cjson.decode(response_string)
|
|
if err then
|
|
return nil, "failed to decode cohere response"
|
|
end
|
|
|
|
-- messages/choices table is only 1 size, so don't need to static allocate
|
|
local messages = {}
|
|
messages.choices = {}
|
|
|
|
if response_table.prompt and response_table.generations then
|
|
-- this is a "co.generate"
|
|
for i, v in ipairs(response_table.generations) do
|
|
messages.choices[i] = {
|
|
index = (i-1),
|
|
text = v.text,
|
|
finish_reason = "stop",
|
|
}
|
|
end
|
|
messages.object = "text_completion"
|
|
messages.model = model_info.name
|
|
messages.id = response_table.id
|
|
|
|
local stats = {
|
|
completion_tokens = response_table.meta
|
|
and response_table.meta.billed_units
|
|
and response_table.meta.billed_units.output_tokens,
|
|
|
|
prompt_tokens = response_table.meta
|
|
and response_table.meta.billed_units
|
|
and response_table.meta.billed_units.input_tokens,
|
|
|
|
total_tokens = response_table.meta
|
|
and response_table.meta.billed_units
|
|
and (response_table.meta.billed_units.output_tokens + response_table.meta.billed_units.input_tokens),
|
|
}
|
|
messages.usage = stats
|
|
|
|
elseif response_table.text then
|
|
-- this is a "co.chat"
|
|
|
|
messages.choices[1] = {
|
|
index = 0,
|
|
message = {
|
|
role = "assistant",
|
|
content = response_table.text,
|
|
},
|
|
finish_reason = "stop",
|
|
}
|
|
messages.object = "chat.completion"
|
|
messages.model = model_info.name
|
|
messages.id = response_table.generation_id
|
|
|
|
local stats = {
|
|
completion_tokens = response_table.meta
|
|
and response_table.meta.billed_units
|
|
and response_table.meta.billed_units.output_tokens,
|
|
|
|
prompt_tokens = response_table.meta
|
|
and response_table.meta.billed_units
|
|
and response_table.meta.billed_units.input_tokens,
|
|
|
|
total_tokens = response_table.meta
|
|
and response_table.meta.billed_units
|
|
and (response_table.meta.billed_units.output_tokens + response_table.meta.billed_units.input_tokens),
|
|
}
|
|
messages.usage = stats
|
|
|
|
elseif response_table.message then
|
|
-- this is a "co.chat"
|
|
|
|
messages.choices[1] = {
|
|
index = 0,
|
|
message = {
|
|
role = "assistant",
|
|
content = response_table.message.tool_plan or response_table.message.content,
|
|
tool_calls = response_table.message.tool_calls
|
|
},
|
|
finish_reason = response_table.finish_reason,
|
|
}
|
|
messages.object = "chat.completion"
|
|
messages.model = model_info.name
|
|
messages.id = response_table.id
|
|
|
|
local stats = {
|
|
completion_tokens = response_table.usage
|
|
and response_table.usage.billed_units
|
|
and response_table.usage.billed_units.output_tokens,
|
|
|
|
prompt_tokens = response_table.usage
|
|
and response_table.usage.billed_units
|
|
and response_table.usage.billed_units.input_tokens,
|
|
|
|
total_tokens = response_table.usage
|
|
and response_table.usage.billed_units
|
|
and (response_table.usage.billed_units.output_tokens + response_table.usage.billed_units.input_tokens),
|
|
}
|
|
messages.usage = stats
|
|
|
|
else -- probably a fault
|
|
return nil, "'text' or 'generations' missing from cohere response body"
|
|
|
|
end
|
|
|
|
return cjson.encode(messages)
|
|
end,
|
|
|
|
["llm/v1/completions"] = function(response_string, model_info)
|
|
local response_table, err = cjson.decode(response_string)
|
|
if err then
|
|
return nil, "failed to decode cohere response"
|
|
end
|
|
|
|
local prompt = {}
|
|
prompt.choices = {}
|
|
|
|
if response_table.prompt and response_table.generations then
|
|
-- this is a "co.generate"
|
|
|
|
for i, v in ipairs(response_table.generations) do
|
|
prompt.choices[i] = {
|
|
index = (i-1),
|
|
text = v.text,
|
|
finish_reason = "stop",
|
|
}
|
|
end
|
|
prompt.object = "text_completion"
|
|
prompt.model = model_info.name
|
|
prompt.id = response_table.id
|
|
|
|
local stats = {
|
|
completion_tokens = response_table.meta and response_table.meta.billed_units.output_tokens,
|
|
prompt_tokens = response_table.meta and response_table.meta.billed_units.input_tokens,
|
|
total_tokens = response_table.meta
|
|
and (response_table.meta.billed_units.output_tokens + response_table.meta.billed_units.input_tokens),
|
|
}
|
|
prompt.usage = stats
|
|
|
|
elseif response_table.text then
|
|
-- this is a "co.chat"
|
|
|
|
prompt.choices[1] = {
|
|
index = 0,
|
|
message = {
|
|
role = "assistant",
|
|
content = response_table.text,
|
|
},
|
|
finish_reason = "stop",
|
|
}
|
|
prompt.object = "chat.completion"
|
|
prompt.model = model_info.name
|
|
prompt.id = response_table.generation_id
|
|
|
|
local stats = {
|
|
completion_tokens = response_table.token_count and response_table.token_count.response_tokens,
|
|
prompt_tokens = response_table.token_count and response_table.token_count.prompt_tokens,
|
|
total_tokens = response_table.token_count and response_table.token_count.total_tokens,
|
|
}
|
|
prompt.usage = stats
|
|
|
|
else -- probably a fault
|
|
return nil, "'text' or 'generations' missing from cohere response body"
|
|
|
|
end
|
|
|
|
return cjson.encode(prompt)
|
|
end,
|
|
|
|
["stream/llm/v1/chat"] = handle_stream_event,
|
|
["stream/llm/v1/completions"] = handle_stream_event,
|
|
}
|
|
|
|
function _M.from_format(response_string, model_info, route_type)
|
|
-- MUST return a string, to set as the response body
|
|
ngx.log(ngx.DEBUG, "converting from ", model_info.provider, "://", route_type, " type to kong")
|
|
|
|
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 request_table.tools then
|
|
return openai_driver.to_format(request_table, model_info, route_type)
|
|
end
|
|
|
|
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", model_info.provider, route_type)
|
|
end
|
|
|
|
return response_object, content_type, nil
|
|
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)
|
|
return true
|
|
end
|
|
|
|
function _M.subrequest(body, conf, http_opts, return_res_table)
|
|
-- 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 url = (conf.model.options and conf.model.options.upstream_url)
|
|
or fmt(
|
|
"%s%s",
|
|
ai_shared.upstream_url_format[DRIVER_NAME],
|
|
ai_shared.operation_map[DRIVER_NAME][conf.route_type].path
|
|
)
|
|
|
|
local method = ai_shared.operation_map[DRIVER_NAME][conf.route_type].method
|
|
|
|
local headers = {
|
|
["Accept"] = "application/json",
|
|
["Content-Type"] = "application/json",
|
|
}
|
|
|
|
if conf.auth and conf.auth.header_name then
|
|
headers[conf.auth.header_name] = conf.auth.header_value
|
|
end
|
|
|
|
local res, err, httpc = ai_shared.http_request(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
|
|
|
|
-- returns err or nil
|
|
function _M.configure_request(conf)
|
|
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 parsed_url
|
|
|
|
if model.options and model.options.upstream_url then
|
|
parsed_url = socket_url.parse(model.options.upstream_url)
|
|
else
|
|
parsed_url = socket_url.parse(ai_shared.upstream_url_format[DRIVER_NAME])
|
|
parsed_url.path = (model.options and
|
|
model.options.upstream_path)
|
|
or (ai_shared.operation_map[DRIVER_NAME][conf.route_type] and
|
|
ai_shared.operation_map[DRIVER_NAME][conf.route_type].path)
|
|
or "/"
|
|
end
|
|
|
|
ai_shared.override_upstream_url(parsed_url, conf, model)
|
|
|
|
|
|
-- 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 "/"
|
|
|
|
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))
|
|
|
|
local auth_header_name = conf.auth and conf.auth.header_name
|
|
local auth_header_value = conf.auth and conf.auth.header_value
|
|
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_header_name and auth_header_value then
|
|
local exist_value = kong.request.get_header(auth_header_name)
|
|
if exist_value == nil or not conf.auth.allow_override then
|
|
kong.service.request.set_header(auth_header_name, auth_header_value)
|
|
end
|
|
end
|
|
|
|
if auth_param_name and auth_param_value and auth_param_location == "query" then
|
|
local query_table = kong.request.get_query()
|
|
if query_table[auth_param_name] == nil or not conf.auth.allow_override then
|
|
query_table[auth_param_name] = auth_param_value
|
|
kong.service.request.set_query(query_table)
|
|
end
|
|
end
|
|
|
|
-- if auth_param_location is "body", it will have already been set in a pre-request hook
|
|
return true, nil
|
|
end
|
|
|
|
return _M
|