This is an automated email from the ASF dual-hosted git repository.
nic-6443 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/apisix.git
The following commit(s) were added to refs/heads/master by this push:
new 52d9d92e4 fix: sort AI proxy upstream request JSON keys (#13461)
52d9d92e4 is described below
commit 52d9d92e47aa9a70846752ddd4bbcb7adb6eaa31
Author: Nic <[email protected]>
AuthorDate: Tue Jun 2 12:09:00 2026 +0800
fix: sort AI proxy upstream request JSON keys (#13461)
---
.github/workflows/source-install.yml | 6 +-
Makefile | 1 +
apisix-master-0.rockspec | 1 +
apisix/plugins/ai-transport/http.lua | 52 +++++-
ci/install-lua-rapidjson.sh | 51 +++++
ci/linux_apisix_current_luarocks_runner.sh | 1 +
docker/debian-dev/Dockerfile | 1 +
t/plugin/ai-transport-http.t | 290 +++++++++++++++++++++++++++++
8 files changed, 399 insertions(+), 4 deletions(-)
diff --git a/.github/workflows/source-install.yml
b/.github/workflows/source-install.yml
index dcedbed47..023aa5886 100644
--- a/.github/workflows/source-install.yml
+++ b/.github/workflows/source-install.yml
@@ -66,19 +66,19 @@ jobs:
run: |
if [[ $INSTALL_PLATFORM == "ubuntu" ]]; then
sudo apt-get update
- sudo apt-get install -y git sudo make
+ sudo apt-get install -y git sudo make cmake g++
make deps
sudo make install
apisix start
elif [[ $INSTALL_PLATFORM == "redhat" ]]; then
docker run -itd -v ${{ github.workspace }}:/apisix --name ubi8
--net="host" --dns 8.8.8.8 --dns-search apache.org
registry.access.redhat.com/ubi8/ubi:8.6 /bin/bash
- docker exec ubi8 bash -c "yum install -y git sudo make"
+ docker exec ubi8 bash -c "yum install -y git sudo make cmake
gcc-c++"
docker exec ubi8 bash -c "cd apisix && make deps"
docker exec ubi8 bash -c "cd apisix && make install"
docker exec ubi8 bash -c "cd apisix && apisix start"
elif [[ $INSTALL_PLATFORM == "centos7" ]]; then
docker run -itd -v ${{ github.workspace }}:/apisix --name
centos7Instance --net="host" --dns 8.8.8.8 --dns-search apache.org
docker.io/centos:7 /bin/bash
- docker exec centos7Instance bash -c "yum install -y git sudo
make"
+ docker exec centos7Instance bash -c "yum install -y git sudo
make cmake gcc-c++"
docker exec centos7Instance bash -c "cd apisix && make deps"
docker exec centos7Instance bash -c "cd apisix && make install"
docker exec centos7Instance bash -c "cd apisix && apisix start"
diff --git a/Makefile b/Makefile
index 5e56349cc..577193764 100644
--- a/Makefile
+++ b/Makefile
@@ -137,6 +137,7 @@ deps: install-runtime
$(ENV_LUAROCKS) config $(ENV_LUAROCKS_FLAG_LOCAL)
variables.OPENSSL_LIBDIR $(addprefix $(ENV_OPENSSL_PREFIX), /lib); \
$(ENV_LUAROCKS) config $(ENV_LUAROCKS_FLAG_LOCAL)
variables.OPENSSL_INCDIR $(addprefix $(ENV_OPENSSL_PREFIX), /include); \
$(ENV_LUAROCKS) config $(ENV_LUAROCKS_FLAG_LOCAL)
variables.YAML_DIR $(ENV_LIBYAML_INSTALL_PREFIX); \
+ LUAROCKS=$(ENV_LUAROCKS) ./ci/install-lua-rapidjson.sh deps; \
$(ENV_LUAROCKS) install apisix-master-0.rockspec --tree deps
--only-deps $(ENV_LUAROCKS_SERVER_OPT); \
else \
$(call func_echo_warn_status, "WARNING: You're not using
LuaRocks 3.x; please remove the luarocks and reinstall it via
https://raw.githubusercontent.com/apache/apisix/master/utils/linux-install-luarocks.sh");
\
diff --git a/apisix-master-0.rockspec b/apisix-master-0.rockspec
index 2279f3767..fab5ae9b1 100644
--- a/apisix-master-0.rockspec
+++ b/apisix-master-0.rockspec
@@ -87,6 +87,7 @@ dependencies = {
"api7-lua-resty-aws == 2.0.2-1",
"multipart = 0.5.11-1",
"luautf8 = 0.2.0-1",
+ "rapidjson = 0.7.2-1",
}
build = {
diff --git a/apisix/plugins/ai-transport/http.lua
b/apisix/plugins/ai-transport/http.lua
index bf8a06c1a..d92ecf5ba 100644
--- a/apisix/plugins/ai-transport/http.lua
+++ b/apisix/plugins/ai-transport/http.lua
@@ -20,13 +20,19 @@
local core = require("apisix.core")
local http = require("resty.http")
+local rapidjson = require("rapidjson")
+local getmetatable = getmetatable
local ngx_now = ngx.now
local pairs = pairs
local ipairs = ipairs
+local pcall = pcall
local type = type
local str_lower = string.lower
+local tostring = tostring
local _M = {}
+local rapidjson_encode_opts = {sort_keys = true}
+local rapidjson_null = rapidjson.null
--- Map network errors to HTTP status codes.
@@ -65,6 +71,50 @@ function _M.construct_forward_headers(ext_opts_headers, ctx)
end
+local function to_rapidjson_value(data)
+ if data == core.json.null then
+ return rapidjson_null
+ end
+
+ if type(data) ~= "table" then
+ return data
+ end
+
+ if getmetatable(data) == core.json.array_mt then
+ local arr = {}
+ for i, v in ipairs(data) do
+ arr[i] = to_rapidjson_value(v)
+ end
+ return rapidjson.array(arr)
+ end
+
+ local obj = {}
+ for k, v in pairs(data) do
+ obj[k] = to_rapidjson_value(v)
+ end
+ return obj
+end
+
+
+local function rapidjson_encode(body)
+ return rapidjson.encode(to_rapidjson_value(body), rapidjson_encode_opts)
+end
+
+
+local function encode_body(body)
+ local ok, encoded = pcall(rapidjson_encode, body)
+ if ok and encoded then
+ return encoded
+ end
+
+ core.log.error("failed to encode AI request body with rapidjson: ",
+ ok and "unknown" or tostring(encoded),
+ ", fallback to cjson; LLM cache hit rate may decrease")
+
+ return core.json.encode(body)
+end
+
+
--- Send an HTTP request to an AI service.
-- Handles the full lifecycle: create client, connect, encode body,
-- send request, and return the response object.
@@ -107,7 +157,7 @@ function _M.request(params, timeout)
req_json = params.body
else
local err
- req_json, err = core.json.encode(params.body)
+ req_json, err = encode_body(params.body)
if not req_json then
httpc:close()
return nil, "encode body: " .. (err or "unknown"), {
diff --git a/ci/install-lua-rapidjson.sh b/ci/install-lua-rapidjson.sh
new file mode 100755
index 000000000..3d723b692
--- /dev/null
+++ b/ci/install-lua-rapidjson.sh
@@ -0,0 +1,51 @@
+#!/usr/bin/env bash
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+set -euo pipefail
+
+RAPIDJSON_VERSION="${RAPIDJSON_VERSION:-0.7.2}"
+ROCKSPEC_VERSION="${ROCKSPEC_VERSION:-0.7.2-1}"
+LUAROCKS_BIN="${LUAROCKS:-luarocks}"
+TREE="${1:-}"
+
+if [ -n "${TREE}" ]; then
+ mkdir -p "${TREE}"
+ TREE="$(cd "${TREE}" && pwd)"
+fi
+
+workdir="$(mktemp -d)"
+trap 'rm -rf "${workdir}"' EXIT
+
+cd "${workdir}"
+"${LUAROCKS_BIN}" unpack rapidjson "${RAPIDJSON_VERSION}"
+cd "rapidjson-${ROCKSPEC_VERSION}/lua-rapidjson"
+
+# lua-rapidjson enables -march=native by default, which can leak the build
+# host's CPU features into the shipped rapidjson.so and crash on older CPUs.
+# Fail loudly if the expected line is gone so a silent native build can't slip
in.
+if ! grep -q 'add_compile_options(-march=native)' CMakeLists.txt; then
+ echo "error: '-march=native' line not found in CMakeLists.txt; upstream
may have changed" >&2
+ exit 1
+fi
+sed -i.bak '/add_compile_options(-march=native)/d' CMakeLists.txt && rm -f
CMakeLists.txt.bak
+
+if [ -n "${TREE}" ]; then
+ "${LUAROCKS_BIN}" make "rapidjson-${ROCKSPEC_VERSION}.rockspec"
--tree="${TREE}"
+else
+ "${LUAROCKS_BIN}" make "rapidjson-${ROCKSPEC_VERSION}.rockspec"
+fi
diff --git a/ci/linux_apisix_current_luarocks_runner.sh
b/ci/linux_apisix_current_luarocks_runner.sh
index 94abcd466..0d2e4e9b8 100755
--- a/ci/linux_apisix_current_luarocks_runner.sh
+++ b/ci/linux_apisix_current_luarocks_runner.sh
@@ -44,6 +44,7 @@ script() {
sudo rm -rf /usr/local/share/lua/5.1/apisix
# install APISIX with local version
+ ./ci/install-lua-rapidjson.sh
luarocks install apisix-master-0.rockspec --only-deps > build.log 2>&1 ||
(cat build.log && exit 1)
luarocks make apisix-master-0.rockspec > build.log 2>&1 || (cat build.log
&& exit 1)
# ensure all files under apisix is installed
diff --git a/docker/debian-dev/Dockerfile b/docker/debian-dev/Dockerfile
index 60bc8b1f3..25358c8de 100644
--- a/docker/debian-dev/Dockerfile
+++ b/docker/debian-dev/Dockerfile
@@ -36,6 +36,7 @@ RUN set -x \
curl \
gcc \
g++ \
+ cmake \
libyaml-dev \
libxml2-dev \
libxslt1-dev \
diff --git a/t/plugin/ai-transport-http.t b/t/plugin/ai-transport-http.t
new file mode 100644
index 000000000..a2cb27883
--- /dev/null
+++ b/t/plugin/ai-transport-http.t
@@ -0,0 +1,290 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+use t::APISIX 'no_plan';
+
+repeat_each(1);
+no_long_string();
+no_root_location();
+
+add_block_preprocessor(sub {
+ my ($block) = @_;
+
+ if (!$block->request) {
+ $block->set_value("request", "GET /t");
+ }
+});
+
+run_tests;
+
+__DATA__
+
+=== TEST 1: AI transport encodes upstream request body with sorted keys and
preserves empty arrays
+--- config
+ location /t {
+ content_by_lua_block {
+ local core = require("apisix.core")
+ local orig_http = package.loaded["resty.http"]
+ local orig_transport =
package.loaded["apisix.plugins.ai-transport.http"]
+
+ package.loaded["resty.http"] = {
+ new = function()
+ return {
+ set_timeout = function() end,
+ connect = function() return true end,
+ request = function(_, params)
+ ngx.say(params.body)
+ return {headers = {}, status = 200}
+ end,
+ }
+ end,
+ }
+
+ package.loaded["apisix.plugins.ai-transport.http"] = nil
+ local transport = require("apisix.plugins.ai-transport.http")
+ local body = core.json.decode([[
+ {
+ "tools": [
+ {
+ "type": "function",
+ "function": {
+ "parameters": {
+ "type": "object",
+ "required": [],
+ "properties": {}
+ },
+ "name": "fn"
+ }
+ }
+ ],
+ "model": "m",
+ "messages": [],
+ "empty_obj": {}
+ }
+ ]])
+
+ local res, err = transport.request({
+ host = "127.0.0.1",
+ port = 80,
+ path = "/",
+ body = body,
+ }, 1000)
+ if not res then
+ ngx.say(err)
+ end
+
+ package.loaded["resty.http"] = orig_http
+ package.loaded["apisix.plugins.ai-transport.http"] = orig_transport
+ }
+ }
+--- response_body
+{"empty_obj":{},"messages":[],"model":"m","tools":[{"function":{"name":"fn","parameters":{"properties":{},"required":[],"type":"object"}},"type":"function"}]}
+
+
+
+=== TEST 2: AI transport falls back to cjson when rapidjson encode fails
+--- config
+ location /t {
+ content_by_lua_block {
+ local core = require("apisix.core")
+ local orig_http = package.loaded["resty.http"]
+ local orig_rapidjson = package.loaded["rapidjson"]
+ local orig_transport =
package.loaded["apisix.plugins.ai-transport.http"]
+
+ package.loaded["rapidjson"] = {
+ encode = function()
+ error("rapidjson failure")
+ end,
+ array = function(data)
+ return data
+ end,
+ object = function(data)
+ return data
+ end,
+ }
+
+ package.loaded["resty.http"] = {
+ new = function()
+ return {
+ set_timeout = function() end,
+ connect = function() return true end,
+ request = function(_, params)
+ local decoded = core.json.decode(params.body)
+ ngx.say("model: ", decoded.model)
+ ngx.say("message role: ", decoded.messages[1].role)
+ return {headers = {}, status = 200}
+ end,
+ }
+ end,
+ }
+
+ package.loaded["apisix.plugins.ai-transport.http"] = nil
+ local transport = require("apisix.plugins.ai-transport.http")
+ local res, err = transport.request({
+ host = "127.0.0.1",
+ port = 80,
+ path = "/",
+ body = {model = "m", messages = {{role = "user", content =
"hi"}}},
+ }, 1000)
+ if not res then
+ ngx.say(err)
+ end
+
+ package.loaded["resty.http"] = orig_http
+ package.loaded["rapidjson"] = orig_rapidjson
+ package.loaded["apisix.plugins.ai-transport.http"] = orig_transport
+ }
+ }
+--- response_body
+model: m
+message role: user
+--- error_log
+failed to encode AI request body with rapidjson:
+
+
+
+=== TEST 3: cjson and rapidjson encode plain empty table fields as objects
+--- config
+ location /t {
+ content_by_lua_block {
+ local core = require("apisix.core")
+ local rapidjson = require("rapidjson")
+
+ local body = {
+ model = "m",
+ empty_table = {},
+ nested = {
+ empty_table = {},
+ },
+ }
+
+ local cjson_body = core.json.encode(body)
+ local rapidjson_body = rapidjson.encode(body, {sort_keys = true})
+ local cjson_decoded = core.json.decode(cjson_body)
+ local rapidjson_decoded = core.json.decode(rapidjson_body)
+
+ ngx.say("cjson body: ", cjson_body)
+ ngx.say("rapidjson body: ", rapidjson_body)
+ ngx.say("cjson empty table: ",
core.json.encode(cjson_decoded.empty_table))
+ ngx.say("rapidjson empty table: ",
core.json.encode(rapidjson_decoded.empty_table))
+ ngx.say("cjson nested empty table: ",
+ core.json.encode(cjson_decoded.nested.empty_table))
+ ngx.say("rapidjson nested empty table: ",
+ core.json.encode(rapidjson_decoded.nested.empty_table))
+ }
+ }
+--- response_body_like
+\Acjson body:
\{(?=.*"empty_table":\{\})(?=.*"nested":\{"empty_table":\{\}\})(?=.*"model":"m").*\}
+rapidjson body:
\{"empty_table":\{\},"model":"m","nested":\{"empty_table":\{\}\}\}
+cjson empty table: \{\}
+rapidjson empty table: \{\}
+cjson nested empty table: \{\}
+rapidjson nested empty table: \{\}
+
+
+
+=== TEST 4: AI transport preserves JSON null values from cjson decode
+--- config
+ location /t {
+ content_by_lua_block {
+ local core = require("apisix.core")
+ local orig_http = package.loaded["resty.http"]
+ local orig_transport =
package.loaded["apisix.plugins.ai-transport.http"]
+
+ package.loaded["resty.http"] = {
+ new = function()
+ return {
+ set_timeout = function() end,
+ connect = function() return true end,
+ request = function(_, params)
+ ngx.say(params.body)
+ return {headers = {}, status = 200}
+ end,
+ }
+ end,
+ }
+
+ package.loaded["apisix.plugins.ai-transport.http"] = nil
+ local transport = require("apisix.plugins.ai-transport.http")
+ local body = core.json.decode([[{"stop":null,"model":"m"}]])
+ local res, err = transport.request({
+ host = "127.0.0.1",
+ port = 80,
+ path = "/",
+ body = body,
+ }, 1000)
+ if not res then
+ ngx.say(err)
+ end
+
+ package.loaded["resty.http"] = orig_http
+ package.loaded["apisix.plugins.ai-transport.http"] = orig_transport
+ }
+ }
+--- response_body
+{"model":"m","stop":null}
+--- no_error_log
+failed to encode AI request body with rapidjson:
+
+
+
+=== TEST 5: AI transport preserves manually constructed arrays
+--- config
+ location /t {
+ content_by_lua_block {
+ local orig_http = package.loaded["resty.http"]
+ local orig_transport =
package.loaded["apisix.plugins.ai-transport.http"]
+
+ package.loaded["resty.http"] = {
+ new = function()
+ return {
+ set_timeout = function() end,
+ connect = function() return true end,
+ request = function(_, params)
+ ngx.say(params.body)
+ return {headers = {}, status = 200}
+ end,
+ }
+ end,
+ }
+
+ package.loaded["apisix.plugins.ai-transport.http"] = nil
+ local transport = require("apisix.plugins.ai-transport.http")
+ local body = {
+ model = "m",
+ messages = {
+ {role = "user", content = "hi"},
+ {role = "assistant", content = "hello"},
+ },
+ }
+ local res, err = transport.request({
+ host = "127.0.0.1",
+ port = 80,
+ path = "/",
+ body = body,
+ }, 1000)
+ if not res then
+ ngx.say(err)
+ end
+
+ package.loaded["resty.http"] = orig_http
+ package.loaded["apisix.plugins.ai-transport.http"] = orig_transport
+ }
+ }
+--- response_body
+{"messages":[{"content":"hi","role":"user"},{"content":"hello","role":"assistant"}],"model":"m"}
+--- no_error_log
+failed to encode AI request body with rapidjson: