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

520 lines
15 KiB
Lua

local helpers = require "spec.helpers"
local cjson = require "cjson"
local pl_file = require "pl.file"
local strip = require("kong.tools.string").strip
local PLUGIN_NAME = "ai-request-transformer"
local FILE_LOG_PATH_STATS_ONLY = os.tmpname()
local FILE_LOG_PATH_GEMINI_STATS_ONLY = os.tmpname()
local function wait_for_json_log_entry(FILE_LOG_PATH)
local json
assert
.with_timeout(10)
.ignore_exceptions(true)
.eventually(function()
local data = assert(pl_file.read(FILE_LOG_PATH))
data = strip(data)
assert(#data > 0, "log file is empty")
data = data:match("%b{}")
assert(data, "log file does not contain JSON")
json = cjson.decode(data)
end)
.has_no_error("log file contains a valid JSON entry")
return json
end
local REQUEST_BODY = [[
{
"persons": [
{
"name": "Kong A",
"age": 31
},
{
"name": "Kong B",
"age": 42
}
]
}
]]
local EXPECTED_RESULT_FLAT = {
persons = {
[1] = {
age = 62,
name = "Kong A"
},
[2] = {
age = 84,
name = "Kong B"
},
}
}
local _EXPECTED_CHAT_STATS = {
["ai-request-transformer"] = {
meta = {
plugin_id = '71083e79-4921-4f9f-97a4-ee7810b6cd8a',
provider_name = 'openai',
request_model = 'UNSPECIFIED',
response_model = 'gpt-3.5-turbo-0613',
llm_latency = 1
},
usage = {
prompt_tokens = 25,
completion_tokens = 12,
total_tokens = 37,
time_per_token = 1,
cost = 0.00037,
},
cache = {}
},
}
local _EXPECTED_CHAT_STATS_GEMINI = {
["ai-request-transformer"] = {
meta = {
plugin_id = '71083e79-4921-4f9f-97a4-ee7810b6cd8b',
provider_name = 'gemini',
request_model = 'UNSPECIFIED',
response_model = 'gemini-1.5-flash',
llm_latency = 1
},
usage = {
prompt_tokens = 2,
completion_tokens = 11,
total_tokens = 13,
time_per_token = 1,
cost = 0.00026,
},
cache = {}
},
}
local SYSTEM_PROMPT = "You are a mathematician. "
.. "Multiply all numbers in my JSON request, by 2."
local client
for _, strategy in helpers.all_strategies() do
describe(PLUGIN_NAME .. ": (access) [#" .. strategy .. "]", function()
local MOCK_PORT
local OPENAI_FLAT_RESPONSE
local GEMINI_GOOD
local GEMINI_GOOD_FAILS_SAFETY
local OPENAI_BAD_REQUEST
local OPENAI_INTERNAL_SERVER_ERROR
lazy_setup(function()
MOCK_PORT = helpers.get_available_port()
OPENAI_FLAT_RESPONSE = {
route_type = "llm/v1/chat",
logging = {
log_payloads = false,
log_statistics = true,
},
model = {
name = "gpt-4",
provider = "openai",
options = {
max_tokens = 512,
temperature = 0.5,
upstream_url = "http://"..helpers.mock_upstream_host..":"..MOCK_PORT.."/flat",
input_cost = 10.0,
output_cost = 10.0,
},
},
auth = {
header_name = "Authorization",
header_value = "Bearer openai-key",
},
}
GEMINI_GOOD = {
route_type = "llm/v1/chat",
logging = {
log_payloads = false,
log_statistics = true,
},
model = {
name = "gemini-1.5-flash",
provider = "gemini",
options = {
max_tokens = 512,
temperature = 0.6,
upstream_url = "http://"..helpers.mock_upstream_host..":"..MOCK_PORT.."/geminiflat",
input_cost = 20.0,
output_cost = 20.0,
},
},
auth = {
header_name = "x-goog-api-key",
header_value = "123",
},
}
GEMINI_GOOD_FAILS_SAFETY = {
route_type = "llm/v1/chat",
logging = {
log_payloads = false,
log_statistics = true,
},
model = {
name = "gemini-1.5-flash",
provider = "gemini",
options = {
max_tokens = 512,
temperature = 0.5,
upstream_url = "http://"..helpers.mock_upstream_host..":"..MOCK_PORT.."/failssafety",
input_cost = 10.0,
output_cost = 10.0,
},
},
auth = {
header_name = "x-goog-api-key",
header_value = "123",
},
}
OPENAI_BAD_REQUEST = {
route_type = "llm/v1/chat",
model = {
name = "gpt-4",
provider = "openai",
options = {
max_tokens = 512,
temperature = 0.5,
upstream_url = "http://"..helpers.mock_upstream_host..":"..MOCK_PORT.."/badrequest"
},
},
auth = {
header_name = "Authorization",
header_value = "Bearer openai-key",
},
}
OPENAI_INTERNAL_SERVER_ERROR = {
route_type = "llm/v1/chat",
model = {
name = "gpt-4",
provider = "openai",
options = {
max_tokens = 512,
temperature = 0.5,
upstream_url = "http://"..helpers.mock_upstream_host..":"..MOCK_PORT.."/internalservererror"
},
},
auth = {
header_name = "Authorization",
header_value = "Bearer openai-key",
},
}
local bp = helpers.get_db_utils(strategy == "off" and "postgres" or strategy, nil, { PLUGIN_NAME })
-- set up provider fixtures
local fixtures = {
http_mock = {},
}
fixtures.http_mock.openai = [[
server {
server_name llm;
listen ]]..MOCK_PORT..[[;
default_type 'application/json';
location ~/flat {
content_by_lua_block {
local pl_file = require "pl.file"
ngx.print(pl_file.read("spec/fixtures/ai-proxy/openai/request-transformer/response-in-json.json"))
}
}
location ~/geminiflat {
content_by_lua_block {
local pl_file = require "pl.file"
ngx.print(pl_file.read("spec/fixtures/ai-proxy/gemini/request-transformer/response-in-json.json"))
}
}
location = "/badrequest" {
content_by_lua_block {
local pl_file = require "pl.file"
ngx.status = 400
ngx.print(pl_file.read("spec/fixtures/ai-proxy/openai/llm-v1-chat/responses/bad_request.json"))
}
}
location = "/failssafety" {
content_by_lua_block {
local pl_file = require "pl.file"
ngx.status = 200
ngx.print(pl_file.read("spec/fixtures/ai-proxy/gemini/llm-v1-chat/responses/fails_safety.json"))
}
}
location = "/internalservererror" {
content_by_lua_block {
local pl_file = require "pl.file"
ngx.status = 500
ngx.header["content-type"] = "text/html"
ngx.print(pl_file.read("spec/fixtures/ai-proxy/openai/llm-v1-chat/responses/internal_server_error.html"))
}
}
}
]]
-- echo server via 'openai' LLM
local without_response_instructions = assert(bp.routes:insert {
paths = { "/echo-flat" }
})
bp.plugins:insert {
name = PLUGIN_NAME,
id = "71083e79-4921-4f9f-97a4-ee7810b6cd8a",
route = { id = without_response_instructions.id },
config = {
prompt = SYSTEM_PROMPT,
llm = OPENAI_FLAT_RESPONSE,
},
}
bp.plugins:insert {
name = "file-log",
route = { id = without_response_instructions.id },
config = {
path = FILE_LOG_PATH_STATS_ONLY,
},
}
-- echo server via 'non-openai' LLM
local gemini_without_response_instructions = assert(bp.routes:insert {
paths = { "/gemini-echo-flat" }
})
bp.plugins:insert {
name = PLUGIN_NAME,
id = "71083e79-4921-4f9f-97a4-ee7810b6cd8b",
route = { id = gemini_without_response_instructions.id },
config = {
prompt = SYSTEM_PROMPT,
llm = GEMINI_GOOD,
},
}
bp.plugins:insert {
name = "file-log",
route = { id = gemini_without_response_instructions.id },
config = {
path = FILE_LOG_PATH_GEMINI_STATS_ONLY,
},
}
local bad_request = assert(bp.routes:insert {
paths = { "/echo-bad-request" }
})
bp.plugins:insert {
name = PLUGIN_NAME,
route = { id = bad_request.id },
config = {
prompt = SYSTEM_PROMPT,
llm = OPENAI_BAD_REQUEST,
},
}
local fails_safety = assert(bp.routes:insert {
paths = { "/echo-fails-safety" }
})
bp.plugins:insert {
name = PLUGIN_NAME,
route = { id = fails_safety.id },
config = {
prompt = SYSTEM_PROMPT,
llm = GEMINI_GOOD_FAILS_SAFETY,
},
}
local internal_server_error = assert(bp.routes:insert {
paths = { "/echo-internal-server-error" }
})
bp.plugins:insert {
name = PLUGIN_NAME,
route = { id = internal_server_error.id },
config = {
prompt = SYSTEM_PROMPT,
llm = OPENAI_INTERNAL_SERVER_ERROR,
},
}
--
-- start kong
assert(helpers.start_kong({
-- set the strategy
database = strategy,
-- use the custom test template to create a local mock server
nginx_conf = "spec/fixtures/custom_nginx.template",
-- make sure our plugin gets loaded
plugins = "bundled," .. PLUGIN_NAME,
-- write & load declarative config, only if 'strategy=off'
declarative_config = strategy == "off" and helpers.make_yaml_file() or nil,
}, nil, nil, fixtures))
end)
lazy_teardown(function()
helpers.stop_kong()
end)
before_each(function()
client = helpers.proxy_client()
end)
after_each(function()
if client then client:close() end
end)
describe("openai response transformer integration", function()
it("transforms properly from LLM", function()
local r = client:get("/echo-flat", {
headers = {
["content-type"] = "application/json",
["accept"] = "application/json",
},
body = REQUEST_BODY,
})
local body = assert.res_status(200 , r)
local body_table, err = cjson.decode(body)
assert.is_nil(err)
assert.same(EXPECTED_RESULT_FLAT, body_table.post_data.params)
end)
it("logs statistics - openai format", function()
local r = client:get("/echo-flat", {
headers = {
["content-type"] = "application/json",
["accept"] = "application/json",
},
body = REQUEST_BODY,
})
local body = assert.res_status(200 , r)
local _, err = cjson.decode(body)
assert.is_nil(err)
local log_message = wait_for_json_log_entry(FILE_LOG_PATH_STATS_ONLY)
assert.same("127.0.0.1", log_message.client_ip)
assert.is_number(log_message.request.size)
assert.is_number(log_message.response.size)
-- test ai-request-transformer stats
local actual_chat_stats = log_message.ai
local actual_llm_latency = actual_chat_stats["ai-request-transformer"].meta.llm_latency
local actual_time_per_token = actual_chat_stats["ai-request-transformer"].usage.time_per_token
local time_per_token = math.floor(actual_llm_latency / actual_chat_stats["ai-request-transformer"].usage.completion_tokens)
log_message.ai["ai-request-transformer"].meta.llm_latency = 1
log_message.ai["ai-request-transformer"].usage.time_per_token = 1
assert.same(_EXPECTED_CHAT_STATS, log_message.ai)
assert.is_true(actual_llm_latency >= 0)
assert.same(actual_time_per_token, time_per_token)
end)
it("logs statistics - non-openai format", function()
local r = client:get("/gemini-echo-flat", {
headers = {
["content-type"] = "application/json",
["accept"] = "application/json",
},
body = REQUEST_BODY,
})
local body = assert.res_status(200 , r)
local _, err = cjson.decode(body)
assert.is_nil(err)
local log_message = wait_for_json_log_entry(FILE_LOG_PATH_GEMINI_STATS_ONLY)
assert.same("127.0.0.1", log_message.client_ip)
assert.is_number(log_message.request.size)
assert.is_number(log_message.response.size)
-- test ai-request-transformer stats
local actual_chat_stats = log_message.ai
local actual_llm_latency = actual_chat_stats["ai-request-transformer"].meta.llm_latency
local actual_time_per_token = actual_chat_stats["ai-request-transformer"].usage.time_per_token
local time_per_token = math.floor(actual_llm_latency / actual_chat_stats["ai-request-transformer"].usage.completion_tokens)
log_message.ai["ai-request-transformer"].meta.llm_latency = 1
log_message.ai["ai-request-transformer"].usage.time_per_token = 1
assert.same(_EXPECTED_CHAT_STATS_GEMINI, log_message.ai)
assert.is_true(actual_llm_latency >= 0)
assert.same(actual_time_per_token, time_per_token)
end)
it("bad request from LLM", function()
local r = client:get("/echo-bad-request", {
headers = {
["content-type"] = "application/json",
["accept"] = "application/json",
},
body = REQUEST_BODY,
})
local body = assert.res_status(400 , r)
local body_table, err = cjson.decode(body)
assert.is_nil(err)
assert.same({ error = { message = "failed to introspect request with AI service: status code 400" }}, body_table)
end)
it("fails Gemini content-safety", function()
local r = client:get("/echo-fails-safety", {
headers = {
["content-type"] = "application/json",
["accept"] = "application/json",
},
body = REQUEST_BODY,
})
local body = assert.res_status(400 , r)
local body_table, err = cjson.decode(body)
assert.is_nil(err)
assert.match_re(body_table.error.message, ".*transformation generation candidate breached Gemini content safety.*")
end)
it("internal server error from LLM", function()
local r = client:get("/echo-internal-server-error", {
headers = {
["content-type"] = "application/json",
["accept"] = "application/json",
},
body = REQUEST_BODY,
})
local body = assert.res_status(400 , r)
local body_table, err = cjson.decode(body)
assert.is_nil(err)
assert.same({ error = { message = "failed to introspect request with AI service: status code 500" }}, body_table)
end)
end)
end)
end