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

739 lines
23 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 buffer = require("string.buffer")
local table_insert = table.insert
local string_lower = string.lower
local ai_plugin_ctx = require("kong.llm.plugin.ctx")
local ai_plugin_base = require("kong.llm.plugin.base")
local pl_string = require "pl.stringx"
--
-- globals
local DRIVER_NAME = "gemini"
local get_global_ctx, _ = ai_plugin_ctx.get_global_accessors(DRIVER_NAME)
--
local _OPENAI_ROLE_MAPPING = {
["system"] = "system",
["user"] = "user",
["assistant"] = "model",
}
local function to_gemini_generation_config(request_table)
return {
["maxOutputTokens"] = request_table.max_tokens,
["stopSequences"] = request_table.stop,
["temperature"] = request_table.temperature,
["topK"] = request_table.top_k,
["topP"] = request_table.top_p,
}
end
local function is_content_safety_failure(content)
return content
and content.candidates
and #content.candidates > 0
and content.candidates[1].finishReason
and content.candidates[1].finishReason == "SAFETY"
end
local function is_response_content(content)
return content
and content.candidates
and #content.candidates > 0
and content.candidates[1].content
and content.candidates[1].content.parts
and #content.candidates[1].content.parts > 0
and content.candidates[1].content.parts[1].text
end
local function is_tool_content(content)
return content
and content.candidates
and #content.candidates > 0
and content.candidates[1].content
and content.candidates[1].content.parts
and #content.candidates[1].content.parts > 0
and content.candidates[1].content.parts[1].functionCall
end
local function is_function_call_message(message)
return message
and message.role
and message.role == "assistant"
and message.tool_calls
and type(message.tool_calls) == "table"
and #message.tool_calls > 0
end
local function has_finish_reason(event)
return event
and event.candidates
and #event.candidates > 0
and event.candidates[1].finishReason
or nil
end
local function handle_stream_event(event_t, model_info, route_type)
-- 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
if event_t.data == ai_shared._CONST.SSE_TERMINATOR then
return ai_shared._CONST.SSE_TERMINATOR, nil, nil
end
local event, err = cjson.decode(event_t.data)
if err then
ngx.log(ngx.WARN, "failed to decode stream event frame from gemini: ", err)
return nil, "failed to decode stream event frame from gemini", nil
end
local finish_reason = has_finish_reason(event) -- may be nil
if is_response_content(event) then
local metadata = {}
metadata.finish_reason = finish_reason
metadata.completion_tokens = event.usageMetadata and event.usageMetadata.candidatesTokenCount or 0
metadata.prompt_tokens = event.usageMetadata and event.usageMetadata.promptTokenCount or 0
local new_event = {
model = model_info.name,
choices = {
[1] = {
delta = {
content = event.candidates[1].content.parts[1].text or "",
role = "assistant",
},
index = 0,
finish_reason = finish_reason
},
},
}
return cjson.encode(new_event), nil, metadata
elseif is_tool_content(event) then
local metadata = {}
metadata.finish_reason = finish_reason
metadata.completion_tokens = event.usageMetadata and event.usageMetadata.candidatesTokenCount or 0
metadata.prompt_tokens = event.usageMetadata and event.usageMetadata.promptTokenCount or 0
if event.candidates and #event.candidates > 0 then
local new_event = {
model = model_info.name,
choices = {
[1] = {
delta = {
tool_calls = {},
},
index = 0,
finish_reason = finish_reason
},
},
}
local function_call_responses = event.candidates[1].content.parts
if function_call_responses and #function_call_responses > 0 then
for i, v in ipairs(function_call_responses) do
new_event.choices[1].delta.tool_calls[i] = {
['function'] = {
name = v.functionCall.name,
arguments = cjson.encode(v.functionCall.args),
},
['type'] = "function",
}
end
end
return cjson.encode(new_event), nil, metadata
end
end
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 {
[1] = {
function_declarations = {}
}
}
out_tools[1].function_declarations[i] = v['function']
end
end
return out_tools
end
local function image_url_to_components(img)
-- determine the protocol from the first 10 bytes
if #img < 10 then
return nil, "image URL is less than 10 bytes, which is not parsable"
end
-- capture only 10 bytes for the 'protocol://' to increase performance
local protocol_parts = pl_string.split(img:sub(1, 6), ":")
if protocol_parts then
local protocol = protocol_parts[1]
-- if the protocol is "data" then we can parse it,
-- otherwise just send it as-is, because it's probably
-- as GCP bucket or https link.
if protocol == "data" then
local coordinates_outer = pl_string.split(img:sub(#protocol+2), ";")
local coordinates_inner = pl_string.split(coordinates_outer[2], ",")
return {
mimetype = coordinates_outer[1],
encoding = coordinates_inner[1],
data = coordinates_inner[2],
}
else
return img
end
end
return nil, "unable to determine the PROTOCOL from the image url"
end
-- expects nil return if part does not match expected format or cannot be transformed
local function openai_part_to_gemini_part(openai_part)
if not openai_part then
return nil
end
local gemini_part
if openai_part.type and openai_part.type == "image_url" then
if not (openai_part.image_url and openai_part.image_url.url) then
return nil, "message part type is 'image_url' but is missing .image_url.url block"
end
local image_components, err = image_url_to_components(openai_part.image_url.url)
if err then
return nil, "could not decode OpenAI image-part, " .. err
end
if image_components and type(image_components) == "table" then
gemini_part = {
inlineData = {
data = image_components.data,
mimeType = image_components.mimetype,
}
}
elseif image_components and type(image_components) == "string" then
gemini_part = {
fileData = {
fileUri = image_components,
mimeType = "image/generic",
}
}
end
elseif openai_part.type and openai_part.type == "text" then
if not openai_part.text then
return nil, "message part type is 'text' but is missing .text block"
end
gemini_part = {
text = openai_part.text,
}
else
return nil, "cannot transform part of type '" .. openai_part.type .. "' to Gemini format"
end
return gemini_part
end
local function to_gemini_chat_openai(request_table, model_info, route_type)
local new_r = {}
if request_table then
if request_table.messages and #request_table.messages > 0 then
local system_prompt
for i, v in ipairs(request_table.messages) do
-- for 'system', we just concat them all into one Gemini instruction
if v.role and v.role == "system" then
system_prompt = system_prompt or buffer.new()
system_prompt:put(v.content or "")
elseif v.role and v.role == "tool" then
-- handle tool execution output
table_insert(new_r.contents, {
role = "function",
parts = {
{
function_response = {
response = {
content = {
v.content,
},
},
name = "get_product_info",
},
},
},
})
elseif is_function_call_message(v) then
-- treat specific 'assistant function call' tool execution input message
local function_calls = {}
for i, t in ipairs(v.tool_calls) do
function_calls[i] = {
function_call = {
name = t['function'].name,
},
}
end
table_insert(new_r.contents, {
role = "function",
parts = function_calls,
})
else
local this_parts = {}
-- for any other role, just construct the chat history as 'parts' type
new_r.contents = new_r.contents or {}
if type(v.content) == "string" then
this_parts = {
{
text = v.content,
},
}
elseif type(v.content) == "table" then
if #v.content > 0 then -- check it has ome kind of array element
for j, part in ipairs(v.content) do
local this_part, err = openai_part_to_gemini_part(part)
if not this_part then
if not err then
err = "message at position " .. i .. ", part at position " .. j .. " does not match expected OpenAI format"
end
return nil, nil, err
end
this_parts[j] = this_part
end
else
return nil, nil, "message at position " .. i .. " does not match expected array format"
end
end
table_insert(new_r.contents, {
role = _OPENAI_ROLE_MAPPING[v.role or "user"], -- default to 'user'
parts = this_parts,
})
end
end
-- This was only added in Gemini 1.5
if system_prompt and model_info.name:sub(1, 10) == "gemini-1.0" then
return nil, nil, "system prompts aren't supported on gemini-1.0 models"
elseif system_prompt then
new_r.systemInstruction = {
parts = {
{
text = system_prompt:get(),
},
},
}
end
end
new_r.generationConfig = to_gemini_generation_config(request_table)
-- handle function calling translation from OpenAI format
new_r.tools = request_table.tools and to_tools(request_table.tools)
new_r.tool_config = request_table.tool_config
end
return new_r, "application/json", nil
end
local function from_gemini_chat_openai(response, model_info, route_type)
local err
if response and (type(response) == "string") then
response, err = cjson.decode(response)
end
if err then
local err_client = "failed to decode response from Gemini"
ngx.log(ngx.ERR, fmt("%s: %s", err_client, err))
return nil, err_client
end
-- messages/choices table is only 1 size, so don't need to static allocate
local messages = {}
messages.choices = {}
messages.model = model_info.name -- openai format always contains the model name
if response.candidates and #response.candidates > 0 then
-- for transformer plugins only
if is_content_safety_failure(response) and
(ai_plugin_base.has_filter_executed("ai-request-transformer-transform-request") or
ai_plugin_base.has_filter_executed("ai-response-transformer-transform-response")) then
local err = "transformation generation candidate breached Gemini content safety"
ngx.log(ngx.ERR, err)
return nil, err
elseif is_response_content(response) then
messages.choices[1] = {
index = 0,
message = {
role = "assistant",
content = response.candidates[1].content.parts[1].text,
},
finish_reason = string_lower(response.candidates[1].finishReason),
}
messages.object = "chat.completion"
messages.model = model_info.name
elseif is_tool_content(response) then
messages.choices[1] = {
index = 0,
message = {
role = "assistant",
tool_calls = {},
},
}
local function_call_responses = response.candidates[1].content.parts
for i, v in ipairs(function_call_responses) do
messages.choices[1].message.tool_calls[i] =
{
['function'] = {
name = v.functionCall.name,
arguments = cjson.encode(v.functionCall.args),
},
}
end
end
-- process analytics
if response.usageMetadata then
messages.usage = {
prompt_tokens = response.usageMetadata.promptTokenCount,
completion_tokens = response.usageMetadata.candidatesTokenCount,
total_tokens = response.usageMetadata.totalTokenCount,
}
end
else -- probably a server fault or other unexpected response
local err = "no generation candidates received from Gemini, or max_tokens too short"
ngx.log(ngx.ERR, err)
return nil, err
end
return cjson.encode(messages)
end
local transformers_to = {
["llm/v1/chat"] = to_gemini_chat_openai,
}
local transformers_from = {
["llm/v1/chat"] = from_gemini_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
local operation = get_global_ctx("stream_mode") and "streamGenerateContent"
or "generateContent"
local f_url = conf.model.options and conf.model.options.upstream_url
if not f_url then -- upstream_url override is not set
-- check if this is "public" or "vertex" gemini deployment
if conf.model.options
and conf.model.options.gemini
and conf.model.options.gemini.api_endpoint
and conf.model.options.gemini.project_id
and conf.model.options.gemini.location_id
then
-- vertex mode
f_url = fmt(ai_shared.upstream_url_format["gemini_vertex"],
conf.model.options.gemini.api_endpoint) ..
fmt(ai_shared.operation_map["gemini_vertex"][conf.route_type].path,
conf.model.options.gemini.project_id,
conf.model.options.gemini.location_id,
conf.model.name,
operation)
else
-- public mode
f_url = ai_shared.upstream_url_format["gemini"] ..
fmt(ai_shared.operation_map["gemini"][conf.route_type].path,
conf.model.name,
operation)
end
end
local method = ai_shared.operation_map[DRIVER_NAME][conf.route_type].method
local headers = {
["Accept"] = "application/json",
["Content-Type"] = "application/json",
}
if identity_interface and identity_interface.interface then
if identity_interface.interface:needsRefresh() then
-- HACK: A bug in lua-resty-gcp tries to re-load the environment
-- variable every time, which fails in nginx
-- Create a whole new interface instead.
-- Memory leaks are mega unlikely because this should only
-- happen about once an hour, and the old one will be
-- cleaned up anyway.
local service_account_json = identity_interface.interface.service_account_json
identity_interface.interface.token = identity_interface.interface:new(service_account_json).token
kong.log.debug("gcp identity token for ", kong.plugin.get_id(), " has been refreshed")
end
headers["Authorization"] = "Bearer " .. identity_interface.interface.token
elseif 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(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)
-- disable gzip for gemini because it breaks streaming
kong.service.request.set_header("Accept-Encoding", "identity")
return true, nil
end
-- returns err or nil
function _M.configure_request(conf, identity_interface)
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
local operation = get_global_ctx("stream_mode") and "streamGenerateContent"
or "generateContent"
local f_url = model.options and model.options.upstream_url
if not f_url then -- upstream_url override is not set
-- check if this is "public" or "vertex" gemini deployment
if model.options
and model.options.gemini
and model.options.gemini.api_endpoint
and model.options.gemini.project_id
and model.options.gemini.location_id
then
-- vertex mode
f_url = fmt(ai_shared.upstream_url_format["gemini_vertex"],
model.options.gemini.api_endpoint) ..
fmt(ai_shared.operation_map["gemini_vertex"][conf.route_type].path,
model.options.gemini.project_id,
model.options.gemini.location_id,
model.name,
operation)
else
-- public mode
f_url = ai_shared.upstream_url_format["gemini"] ..
fmt(ai_shared.operation_map["gemini"][conf.route_type].path,
model.name,
operation)
end
end
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
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
-- DBO restrictions makes sure that only one of these auth blocks runs in one plugin config
if auth_header_name and auth_header_value then
kong.service.request.set_header(auth_header_name, auth_header_value)
end
if auth_param_name and auth_param_value and auth_param_location == "query" then
local query_table = kong.request.get_query()
query_table[auth_param_name] = auth_param_value
kong.service.request.set_query(query_table)
end
-- if auth_param_location is "form", it will have already been set in a global pre-request hook
-- if we're passed a GCP SDK, for cloud identity / SSO, use it appropriately
if identity_interface then
if identity_interface:needsRefresh() then
-- HACK: A bug in lua-resty-gcp tries to re-load the environment
-- variable every time, which fails in nginx
-- Create a whole new interface instead.
-- Memory leaks are mega unlikely because this should only
-- happen about once an hour, and the old one will be
-- cleaned up anyway.
local service_account_json = identity_interface.service_account_json
local identity_interface_new = identity_interface:new(service_account_json)
identity_interface.token = identity_interface_new.token
kong.log.debug("gcp identity token for ", kong.plugin.get_id(), " has been refreshed")
end
kong.service.request.set_header("Authorization", "Bearer " .. identity_interface.token)
end
return true
end
if _G._TEST then
-- export locals for testing
_M._to_tools = to_tools
_M._to_gemini_chat_openai = to_gemini_chat_openai
_M._from_gemini_chat_openai = from_gemini_chat_openai
_M._openai_part_to_gemini_part = openai_part_to_gemini_part
end
return _M