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:

Reply via email to