This is an automated email from the ASF dual-hosted git repository.
shreemaan-abhishek 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 7ea42d45dc feat(ai-proxy): send LLM requests through
ngx_http_ffi_client (#13778)
7ea42d45dc is described below
commit 7ea42d45dcb0c3a7f3e263338d0ef816d0873705
Author: Shreemaan Abhishek <[email protected]>
AuthorDate: Tue Aug 18 13:56:15 2026 +0800
feat(ai-proxy): send LLM requests through ngx_http_ffi_client (#13778)
---
.requirements | 2 +-
apisix/cli/config.lua | 3 +
apisix/plugins/ai-transport/http.lua | 42 +-
apisix/utils/http.lua | 125 ++++++
ci/linux-install-openresty.sh | 6 +-
conf/config.yaml.example | 3 +
docs/en/latest/plugins/ai-proxy-multi.md | 17 +
docs/en/latest/plugins/ai-proxy.md | 17 +
t/plugin/ai-transport-http.t | 690 +++++++++++++++++++++++++++++++
9 files changed, 899 insertions(+), 6 deletions(-)
diff --git a/.requirements b/.requirements
index 8bd4cf8ae8..9c76f3ee01 100644
--- a/.requirements
+++ b/.requirements
@@ -17,5 +17,5 @@
APISIX_PACKAGE_NAME=apisix
-APISIX_RUNTIME=1.3.14
+APISIX_RUNTIME=1.3.16
APISIX_DASHBOARD_COMMIT=c8d3466d3c36386d3888efbc8250cd8183c77298
diff --git a/apisix/cli/config.lua b/apisix/cli/config.lua
index ab34d8469a..4315794f21 100644
--- a/apisix/cli/config.lua
+++ b/apisix/cli/config.lua
@@ -309,6 +309,9 @@ local _M = {
},
stream_plugins = { "ip-restriction", "limit-conn", "mqtt-proxy", "syslog",
"traffic-split" },
plugin_attr = {
+ ["ai-proxy"] = {
+ http_client = "ngx_http_ffi_client"
+ },
["log-rotate"] = {
timeout = 10000,
interval = 3600,
diff --git a/apisix/plugins/ai-transport/http.lua
b/apisix/plugins/ai-transport/http.lua
index a2075aa3f4..ee72fd7a33 100644
--- a/apisix/plugins/ai-transport/http.lua
+++ b/apisix/plugins/ai-transport/http.lua
@@ -19,7 +19,7 @@
-- Provides HTTP client lifecycle management for AI provider requests.
local core = require("apisix.core")
-local http = require("resty.http")
+local http_client = require("apisix.utils.http")
local ngx_now = ngx.now
local pairs = pairs
local ipairs = ipairs
@@ -28,8 +28,41 @@ local type = type
local str_lower = string.lower
local tostring = tostring
+local attr_schema = {
+ type = "object",
+ properties = {
+ http_client = http_client.client_schema,
+ },
+}
+
local _M = {}
+local client_name
+
+
+--- Which client this transport should use.
+-- `plugin_attr.ai-proxy.http_client` names it; the shared module owns the
+-- names, validation and loading. Read on first request, because local_conf is
+-- not readable while this module is still loading.
+local function resolve_client_name()
+ if client_name then
+ return client_name
+ end
+
+ local local_conf = core.config.local_conf()
+ local attr = core.table.try_read_attr(local_conf, "plugin_attr",
"ai-proxy") or {}
+
+ local ok, err = core.schema.check(attr_schema, attr)
+ if not ok then
+ core.log.error("invalid plugin_attr.ai-proxy: ", err)
+ return nil, "invalid plugin_attr.ai-proxy: " .. err
+ end
+
+ client_name = attr.http_client or http_client.DEFAULT_CLIENT
+
+ return client_name
+end
+
--- Map network errors to HTTP status codes.
-- Cosocket timers report "timeout"; OS errno (ETIMEDOUT) and the resolver
@@ -100,7 +133,12 @@ end
-- @return string|nil Error message
-- @return table|nil Upstream metadata on failure (for recording failed
attempts)
function _M.request(params, timeout)
- local httpc, err = http.new()
+ local name, name_err = resolve_client_name()
+ if not name then
+ return nil, "failed to create http client: " .. name_err
+ end
+
+ local httpc, err = http_client.new(name)
if not httpc then
return nil, "failed to create http client: " .. (err or "unknown")
end
diff --git a/apisix/utils/http.lua b/apisix/utils/http.lua
new file mode 100644
index 0000000000..d63be0e13f
--- /dev/null
+++ b/apisix/utils/http.lua
@@ -0,0 +1,125 @@
+--
+-- 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.
+--
+
+--- Outbound HTTP client selection.
+-- Shared by any module that makes outbound HTTP calls, so the choice between
+-- `ngx_http_ffi_client` and `lua-resty-http` is made in one place rather than
+-- per plugin. The caller decides where the preference comes from (its own
+-- config key) and passes the name in.
+
+local core = require("apisix.core")
+local pcall = pcall
+local require = require
+local tostring = tostring
+local type = type
+
+local FFI_CLIENT = "ngx_http_ffi_client"
+local LUA_RESTY_HTTP = "lua-resty-http"
+
+-- the client name used in configuration is not the module name
+local CLIENT_MODULES = {
+ [FFI_CLIENT] = "resty.ngx_http_ffi_client",
+ [LUA_RESTY_HTTP] = "resty.http",
+}
+
+local loaded = {}
+
+
+local _M = {
+ version = 0.1,
+ FFI_CLIENT = FFI_CLIENT,
+ LUA_RESTY_HTTP = LUA_RESTY_HTTP,
+ DEFAULT_CLIENT = FFI_CLIENT,
+}
+
+
+--- Schema fragment for a client-name config field.
+-- Callers embed this in their own attribute schema so every module validates
+-- the name the same way.
+_M.client_schema = {
+ type = "string",
+ enum = {FFI_CLIENT, LUA_RESTY_HTTP},
+ default = FFI_CLIENT,
+}
+
+
+--- Load the module for a client name.
+-- `ngx_http_ffi_client` is a C client with the same object API as
+-- lua-resty-http and around half its outbound CPU cost, and it exists only
+-- when the gateway runtime was built with the module. A name that cannot be
+-- loaded is an error, never a silent switch to the other client.
+-- Cached per name once loaded, so a failure is retried rather than remembered.
+local function load_client(name)
+ local cached = loaded[name]
+ if cached then
+ return cached
+ end
+
+ local module_name = CLIENT_MODULES[name]
+ if not module_name then
+ return nil, "unknown http client: " .. tostring(name)
+ end
+
+ local ok, mod = pcall(require, module_name)
+ if not ok or type(mod) ~= "table" then
+ core.log.error(module_name, " is not available: ", mod)
+ return nil, module_name .. " is not available: " .. tostring(mod)
+ end
+
+ -- Cosockets have their names resolved by apisix/patch.lua, which routes
+ -- them through core.resolver and so honours dns_resolver, /etc/hosts and
+ -- the search domains. The C client dials from C and never touches a
+ -- cosocket, so without this it would see only nginx's `resolver`. Handing
+ -- it the same resolver keeps every outbound name on one set of rules.
+ -- A client too old to take one is an error rather than a client quietly
+ -- resolving names by different rules than the rest of the gateway.
+ if name == FFI_CLIENT then
+ if type(mod.set_resolver) ~= "function" then
+ core.log.error(module_name, " does not support set_resolver, ",
+ "the runtime is older than the pinned one")
+ return nil, module_name .. " does not support set_resolver, "
+ .. "the runtime is older than the pinned one"
+ end
+
+ mod.set_resolver(core.resolver.parse_domain)
+ end
+
+ loaded[name] = mod
+
+ return mod
+end
+
+
+--- Create an HTTP client.
+-- @tparam string|nil name client name; defaults to DEFAULT_CLIENT
+-- @treturn table|nil the client
+-- @treturn string|nil error message
+function _M.new(name)
+ name = name or _M.DEFAULT_CLIENT
+
+ local mod, err = load_client(name)
+ if not mod then
+ return nil, err
+ end
+
+ -- The Lua half of `ngx_http_ffi_client` loads even when the C module is
+ -- not compiled into the runtime; new() is what reports that.
+ return mod.new()
+end
+
+
+return _M
diff --git a/ci/linux-install-openresty.sh b/ci/linux-install-openresty.sh
index 07f05e82b8..ffdab29ad5 100755
--- a/ci/linux-install-openresty.sh
+++ b/ci/linux-install-openresty.sh
@@ -61,7 +61,7 @@ else
sudo apt-get -y update --fix-missing
sudo apt-get install -y build-essential gcc g++ cpanminus libxml2-dev
libxslt-dev
- if [ "$APISIX_RUNTIME" != "1.3.14" ]; then
+ if [ "$APISIX_RUNTIME" != "1.3.16" ]; then
echo "Please update the apisix-runtime-debug checksum for
APISIX_RUNTIME=$APISIX_RUNTIME" >&2
exit 1
fi
@@ -69,11 +69,11 @@ else
case "$ARCH" in
x86_64|amd64)
DEB_ARCH="amd64"
-
EXPECTED_SHA256="2d2350347c982e4467ff9326b5b93fcb9af2089b33b02bcd426e84a5adacf6f2"
+
EXPECTED_SHA256="a56f0adc9bf6f6a491f7548df4f8e45fa3df3dd5e209d4a2ba5341b66eb7e060"
;;
arm64|aarch64)
DEB_ARCH="arm64"
-
EXPECTED_SHA256="495320e6377b96ab8d8a80a980a845e346c88160e2798ba46113a4da9042af4d"
+
EXPECTED_SHA256="b645ee4f5ea36d26aaacb1b1c8278d89756b0b8448701ec561ab982b4768204a"
;;
*)
echo "Unsupported architecture: $ARCH" >&2
diff --git a/conf/config.yaml.example b/conf/config.yaml.example
index 26954e2797..6f6c791ea1 100644
--- a/conf/config.yaml.example
+++ b/conf/config.yaml.example
@@ -659,6 +659,9 @@ stream_plugins: # stream plugin list
(sorted by priority)
# protocols:
# - name: pingpong
plugin_attr: # Plugin attributes
+ ai-proxy: # Plugin: ai-proxy, ai-proxy-multi
+ http_client: ngx_http_ffi_client # HTTP client the AI plugins use to reach
the
+ # LLM upstream: ngx_http_ffi_client or lua-resty-http.
log-rotate: # Plugin: log-rotate
timeout: 10000 # maximum wait time for a log rotation(unit: millisecond)
interval: 3600 # Set the log rotate interval in seconds.
diff --git a/docs/en/latest/plugins/ai-proxy-multi.md
b/docs/en/latest/plugins/ai-proxy-multi.md
index dc58ca565f..ea26402360 100644
--- a/docs/en/latest/plugins/ai-proxy-multi.md
+++ b/docs/en/latest/plugins/ai-proxy-multi.md
@@ -152,6 +152,23 @@ By default, `ai-proxy-multi` forwards the incoming client
request headers to the
Because the LLM upstream is often a third-party service, be aware that any
header the client sends (for example `Authorization`, `Cookie`, or internal
application headers) is forwarded to that provider unless it is overridden by
`auth.header`. If the client should not expose certain headers to the LLM
provider, strip them before the request reaches `ai-proxy-multi`, for example
with the [`proxy-rewrite`](./proxy-rewrite.md) plugin.
+## Upstream HTTP Client
+
+Requests to the LLM upstream go through `ngx_http_ffi_client`, a C HTTP client
that costs around half the outbound CPU time of `lua-resty-http`. Both clients
behave the same on the wire.
+
+`plugin_attr.ai-proxy.http_client` in `config.yaml` names the client:
+
+```yaml
+plugin_attr:
+ ai-proxy:
+ http_client: ngx_http_ffi_client # or lua-resty-http
+```
+
+- `ngx_http_ffi_client` (default): the C client. It requires an APISIX runtime
built with the module, which the runtime pinned in `.requirements` is. On a
runtime without it, the request fails and the error names the missing module;
the plugin never silently switches clients.
+- `lua-resty-http`: the Lua client, on every runtime.
+
+The setting covers `ai-proxy`, `ai-proxy-multi`, and `ai-request-rewrite`,
which share the same transport.
+
## Upstream Error Responses
When the selected LLM upstream returns a `429` or `5xx` status,
`ai-proxy-multi` reads the upstream error body before deciding whether to fall
back:
diff --git a/docs/en/latest/plugins/ai-proxy.md
b/docs/en/latest/plugins/ai-proxy.md
index b93702ed00..c9f132bbb6 100644
--- a/docs/en/latest/plugins/ai-proxy.md
+++ b/docs/en/latest/plugins/ai-proxy.md
@@ -145,6 +145,23 @@ By default, `ai-proxy` forwards the incoming client
request headers to the confi
Because the LLM upstream is often a third-party service, be aware that any
header the client sends (for example `Authorization`, `Cookie`, or internal
application headers) is forwarded to that provider unless it is overridden by
`auth.header`. If the client should not expose certain headers to the LLM
provider, strip them before the request reaches `ai-proxy`, for example with
the [`proxy-rewrite`](./proxy-rewrite.md) plugin.
+## Upstream HTTP Client
+
+Requests to the LLM upstream go through `ngx_http_ffi_client`, a C HTTP client
that costs around half the outbound CPU time of `lua-resty-http`. Both clients
behave the same on the wire.
+
+`plugin_attr.ai-proxy.http_client` in `config.yaml` names the client:
+
+```yaml
+plugin_attr:
+ ai-proxy:
+ http_client: ngx_http_ffi_client # or lua-resty-http
+```
+
+- `ngx_http_ffi_client` (default): the C client. It requires an APISIX runtime
built with the module, which the runtime pinned in `.requirements` is. On a
runtime without it, the request fails and the error names the missing module;
the plugin never silently switches clients.
+- `lua-resty-http`: the Lua client, on every runtime.
+
+The setting covers `ai-proxy`, `ai-proxy-multi`, and `ai-request-rewrite`,
which share the same transport.
+
## Upstream Error Responses
When the LLM upstream returns a `429` or `5xx` status, `ai-proxy` reads the
upstream error body and returns it to the client together with the upstream
status code and `Content-Type`, so provider-side error details (such as
rate-limit information or validation errors) are not discarded.
diff --git a/t/plugin/ai-transport-http.t b/t/plugin/ai-transport-http.t
index b475164939..58e23a6cf7 100644
--- a/t/plugin/ai-transport-http.t
+++ b/t/plugin/ai-transport-http.t
@@ -33,6 +33,10 @@ run_tests;
__DATA__
=== TEST 1: AI transport encodes upstream request body with sorted keys and
preserves empty arrays
+--- extra_yaml_config
+plugin_attr:
+ ai-proxy:
+ http_client: lua-resty-http
--- config
location /t {
content_by_lua_block {
@@ -96,6 +100,10 @@ __DATA__
=== TEST 2: AI transport falls back to cjson when rapidjson encode fails
+--- extra_yaml_config
+plugin_attr:
+ ai-proxy:
+ http_client: lua-resty-http
--- config
location /t {
content_by_lua_block {
@@ -197,6 +205,10 @@ rapidjson nested empty table: \{\}
=== TEST 4: AI transport preserves JSON null values from cjson decode
+--- extra_yaml_config
+plugin_attr:
+ ai-proxy:
+ http_client: lua-resty-http
--- config
location /t {
content_by_lua_block {
@@ -242,6 +254,10 @@ failed to encode AI request body with rapidjson:
=== TEST 5: AI transport preserves manually constructed arrays
+--- extra_yaml_config
+plugin_attr:
+ ai-proxy:
+ http_client: lua-resty-http
--- config
location /t {
content_by_lua_block {
@@ -292,6 +308,10 @@ failed to encode AI request body with rapidjson:
=== TEST 6: connect timeout ("Operation timed out") maps to 504
+--- extra_yaml_config
+plugin_attr:
+ ai-proxy:
+ http_client: lua-resty-http
--- config
location /t {
content_by_lua_block {
@@ -353,3 +373,673 @@ connect: operation timed out => 504
connect: Operation timed out => 504
request: connection refused => 500
request: connection reset by peer => 500
+
+
+
+=== TEST 8: ngx_http_ffi_client is the default client
+--- config
+ location /t {
+ content_by_lua_block {
+ local orig_http = package.loaded["resty.http"]
+ local orig_ffi = package.loaded["resty.ngx_http_ffi_client"]
+ local orig_transport =
package.loaded["apisix.plugins.ai-transport.http"]
+
+ package.loaded["resty.http"] = {
+ new = function()
+ ngx.say("lua-resty-http client created")
+ return {
+ set_timeout = function() end,
+ connect = function() return true end,
+ request = function() return {headers = {}, status =
200} end,
+ }
+ end,
+ }
+
+ package.loaded["resty.ngx_http_ffi_client"] = {
+ set_resolver = function() end,
+ new = function()
+ return {
+ set_timeout = function() end,
+ connect = function() return 1 end,
+ request = function(_, params)
+ ngx.say("ffi client request: ", 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 res, err = transport.request({
+ host = "127.0.0.1",
+ port = 80,
+ path = "/",
+ body = {model = "m"},
+ }, 1000)
+ ngx.say("status: ", res and res.status or err)
+
+ package.loaded["resty.http"] = orig_http
+ package.loaded["resty.ngx_http_ffi_client"] = orig_ffi
+ package.loaded["apisix.plugins.ai-transport.http"] = orig_transport
+ }
+ }
+--- response_body
+ffi client request: {"model":"m"}
+status: 200
+
+
+
+=== TEST 9: a runtime without the C module fails the request
+--- config
+ location /t {
+ content_by_lua_block {
+ local orig_http = package.loaded["resty.http"]
+ local orig_ffi = package.loaded["resty.ngx_http_ffi_client"]
+ local orig_transport =
package.loaded["apisix.plugins.ai-transport.http"]
+
+ package.loaded["resty.http"] = {
+ new = function()
+ ngx.say("lua-resty-http client created")
+ return {
+ set_timeout = function() end,
+ connect = function() return true end,
+ request = function() return {headers = {}, status =
200} end,
+ }
+ end,
+ }
+
+ -- the Lua half loads even when the C module is not built in
+ package.loaded["resty.ngx_http_ffi_client"] = {
+ set_resolver = function() end,
+ new = function()
+ return nil, "ngx_http_ffi_client_module is not loaded"
+ end,
+ }
+
+ package.loaded["apisix.plugins.ai-transport.http"] = nil
+ local transport = require("apisix.plugins.ai-transport.http")
+ for _ = 1, 2 do
+ local res, err = transport.request({
+ host = "127.0.0.1",
+ port = 80,
+ path = "/",
+ body = {model = "m"},
+ }, 1000)
+ ngx.say("status: ", res and res.status or err)
+ end
+
+ package.loaded["resty.http"] = orig_http
+ package.loaded["resty.ngx_http_ffi_client"] = orig_ffi
+ package.loaded["apisix.plugins.ai-transport.http"] = orig_transport
+ }
+ }
+--- response_body
+status: failed to create http client: ngx_http_ffi_client_module is not loaded
+status: failed to create http client: ngx_http_ffi_client_module is not loaded
+
+
+
+=== TEST 10: plugin_attr.ai-proxy.http_client selects lua-resty-http
+--- extra_yaml_config
+plugin_attr:
+ ai-proxy:
+ http_client: lua-resty-http
+--- config
+ location /t {
+ content_by_lua_block {
+ local orig_http = package.loaded["resty.http"]
+ local orig_ffi = package.loaded["resty.ngx_http_ffi_client"]
+ 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("lua-resty-http request: ", params.body)
+ return {headers = {}, status = 200}
+ end,
+ }
+ end,
+ }
+
+ package.loaded["resty.ngx_http_ffi_client"] = {
+ new = function()
+ return {
+ set_timeout = function() end,
+ connect = function() return 1 end,
+ request = function()
+ ngx.say("ffi client request")
+ 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"},
+ }, 1000)
+ ngx.say("status: ", res and res.status or err)
+
+ package.loaded["resty.http"] = orig_http
+ package.loaded["resty.ngx_http_ffi_client"] = orig_ffi
+ package.loaded["apisix.plugins.ai-transport.http"] = orig_transport
+ }
+ }
+--- response_body
+lua-resty-http request: {"model":"m"}
+status: 200
+
+
+
+=== TEST 11: the lua-resty-http path reaches a real upstream
+--- extra_yaml_config
+plugin_attr:
+ ai-proxy:
+ http_client: lua-resty-http
+--- config
+ location = /mock-llm {
+ content_by_lua_block {
+ ngx.req.read_body()
+ ngx.header["Content-Type"] = "application/json"
+ ngx.print('{"echo":', ngx.req.get_body_data(), '}')
+ }
+ }
+
+ location /t {
+ content_by_lua_block {
+ local orig_ffi = package.loaded["resty.ngx_http_ffi_client"]
+
+ -- lua-resty-http stays real; only the client that must not be
+ -- picked is stubbed, so a regression in the selection shows up
+ package.loaded["resty.ngx_http_ffi_client"] = {
+ new = function()
+ ngx.log(ngx.ERR, "unexpected ngx_http_ffi_client
selection")
+ return nil, "unexpected ngx_http_ffi_client selection"
+ end,
+ }
+
+ local transport = require("apisix.plugins.ai-transport.http")
+ local res, err = transport.request({
+ method = "POST",
+ scheme = "http",
+ host = "127.0.0.1",
+ port = 1984,
+ path = "/mock-llm",
+ headers = {["content-type"] = "application/json"},
+ body = {model = "m"},
+ }, 2000)
+
+ package.loaded["resty.ngx_http_ffi_client"] = orig_ffi
+
+ if not res then
+ ngx.say("err: ", err)
+ return
+ end
+
+ ngx.say("status: ", res.status)
+ ngx.say("content-type: ", res.headers["Content-Type"])
+ ngx.say("body: ", res:read_body())
+ transport.set_keepalive(res, 60000, 30)
+ }
+ }
+--- response_body
+status: 200
+content-type: application/json
+body: {"echo":{"model":"m"}}
+--- no_error_log
+[error]
+unexpected ngx_http_ffi_client selection
+
+
+
+=== TEST 12: a module that loads but is not a table fails the request
+--- config
+ location /t {
+ content_by_lua_block {
+ local orig_http = package.loaded["resty.http"]
+ local orig_ffi = package.loaded["resty.ngx_http_ffi_client"]
+ local orig_transport =
package.loaded["apisix.plugins.ai-transport.http"]
+
+ package.loaded["resty.http"] = {
+ new = function()
+ ngx.say("lua-resty-http client created")
+ return {
+ set_timeout = function() end,
+ connect = function() return true end,
+ request = function() return {headers = {}, status =
200} end,
+ }
+ end,
+ }
+
+ -- require() returns this instead of the module table
+ package.loaded["resty.ngx_http_ffi_client"] = "not a module"
+
+ 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"},
+ }, 1000)
+ ngx.say("status: ", res and res.status or err)
+
+ package.loaded["resty.http"] = orig_http
+ package.loaded["resty.ngx_http_ffi_client"] = orig_ffi
+ package.loaded["apisix.plugins.ai-transport.http"] = orig_transport
+ }
+ }
+--- response_body
+status: failed to create http client: resty.ngx_http_ffi_client is not
available: not a module
+--- error_log
+resty.ngx_http_ffi_client is not available: not a module
+
+
+
+=== TEST 13: an unknown plugin_attr.ai-proxy.http_client fails the request
+--- extra_yaml_config
+plugin_attr:
+ ai-proxy:
+ http_client: curl
+--- 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()
+ ngx.say("lua-resty-http client created")
+ return {
+ set_timeout = function() end,
+ connect = function() return true end,
+ request = function() 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"},
+ }, 1000)
+ ngx.say("status: ", res and res.status or err)
+
+ package.loaded["resty.http"] = orig_http
+ package.loaded["apisix.plugins.ai-transport.http"] = orig_transport
+ }
+ }
+--- response_body
+status: failed to create http client: invalid plugin_attr.ai-proxy: property
"http_client" validation failed: matches none of the enum values
+--- error_log
+invalid plugin_attr.ai-proxy: property "http_client" validation failed
+
+
+
+=== TEST 14: the C client is given the gateway's resolver
+--- config
+ location /t {
+ content_by_lua_block {
+ local core = require("apisix.core")
+ local orig_ffi = package.loaded["resty.ngx_http_ffi_client"]
+ local orig_utils = package.loaded["apisix.utils.http"]
+
+ local installed
+ package.loaded["resty.ngx_http_ffi_client"] = {
+ set_resolver = function(fn)
+ installed = fn
+ end,
+ new = function()
+ return {}
+ end,
+ }
+
+ package.loaded["apisix.utils.http"] = nil
+ local http_client = require("apisix.utils.http")
+ http_client.new(http_client.FFI_CLIENT)
+
+ -- the C client dials on its own, so what it resolves names with
has
+ -- to be the resolver every other socket in the gateway uses
+ ngx.say("gateway resolver installed: ",
+ installed == core.resolver.parse_domain)
+ ngx.say("resolves through it: ", installed("localhost"))
+
+ package.loaded["resty.ngx_http_ffi_client"] = orig_ffi
+ package.loaded["apisix.utils.http"] = orig_utils
+ }
+ }
+--- response_body
+gateway resolver installed: true
+resolves through it: 127.0.0.1
+--- no_error_log
+[error]
+
+
+
+=== TEST 15: a module whose loader raises fails the request
+--- config
+ location /t {
+ content_by_lua_block {
+ local orig_http = package.loaded["resty.http"]
+ local orig_ffi = package.loaded["resty.ngx_http_ffi_client"]
+ local orig_preload = package.preload["resty.ngx_http_ffi_client"]
+ local orig_transport =
package.loaded["apisix.plugins.ai-transport.http"]
+
+ package.loaded["resty.http"] = {
+ new = function()
+ ngx.say("lua-resty-http client created")
+ return {
+ set_timeout = function() end,
+ connect = function() return true end,
+ request = function() return {headers = {}, status =
200} end,
+ }
+ end,
+ }
+
+ -- an unloaded module whose loader raises: this is what a require()
+ -- failure looks like, as opposed to one that loads the wrong thing
+ package.loaded["resty.ngx_http_ffi_client"] = nil
+ package.preload["resty.ngx_http_ffi_client"] = function()
+ error("simulated loader failure", 0)
+ 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"},
+ }, 1000)
+ ngx.say("status: ", res and res.status or err)
+
+ package.loaded["resty.http"] = orig_http
+ package.loaded["resty.ngx_http_ffi_client"] = orig_ffi
+ package.preload["resty.ngx_http_ffi_client"] = orig_preload
+ package.loaded["apisix.plugins.ai-transport.http"] = orig_transport
+ }
+ }
+--- response_body
+status: failed to create http client: resty.ngx_http_ffi_client is not
available: simulated loader failure
+--- error_log
+resty.ngx_http_ffi_client is not available: simulated loader failure
+
+
+
+=== TEST 16: the C client carries a buffered request to a real upstream
+--- skip_eval: 3: system((($ENV{TEST_NGINX_BINARY} || "nginx") . " -V 2>&1 |
grep -q ngx_http_ffi_client")) != 0
+--- config
+ location = /mock-buffered {
+ content_by_lua_block {
+ ngx.req.read_body()
+ ngx.header["Content-Type"] = "application/json"
+ ngx.print('{"echo":', ngx.req.get_body_data(), '}')
+ }
+ }
+
+ location /t {
+ content_by_lua_block {
+ -- nothing stubbed: this is the real C client on the default
setting
+ local transport = require("apisix.plugins.ai-transport.http")
+ local res, err = transport.request({
+ method = "POST",
+ scheme = "http",
+ host = "127.0.0.1",
+ port = 1984,
+ path = "/mock-buffered",
+ headers = {["content-type"] = "application/json"},
+ body = {model = "m"},
+ }, 2000)
+ if not res then
+ ngx.say("err: ", err)
+ return
+ end
+ ngx.say("status: ", res.status)
+ ngx.say("content-type: ", res.headers["Content-Type"])
+ ngx.say("body: ", res:read_body())
+ transport.set_keepalive(res, 60000, 30)
+ }
+ }
+--- response_body
+status: 200
+content-type: application/json
+body: {"echo":{"model":"m"}}
+--- no_error_log
+[error]
+
+
+
+=== TEST 17: the C client streams an SSE response through body_reader
+--- skip_eval: 3: system((($ENV{TEST_NGINX_BINARY} || "nginx") . " -V 2>&1 |
grep -q ngx_http_ffi_client")) != 0
+--- config
+ location = /mock-sse {
+ content_by_lua_block {
+ ngx.header["Content-Type"] = "text/event-stream"
+ for i = 1, 3 do
+ ngx.print("data: {\"n\":", i, "}\n\n")
+ ngx.flush(true)
+ end
+ ngx.print("data: [DONE]\n\n")
+ ngx.flush(true)
+ }
+ }
+
+ location /t {
+ content_by_lua_block {
+ local transport = require("apisix.plugins.ai-transport.http")
+ local res, err = transport.request({
+ method = "POST",
+ scheme = "http",
+ host = "127.0.0.1",
+ port = 1984,
+ path = "/mock-sse",
+ headers = {["content-type"] = "application/json"},
+ body = {model = "m", stream = true},
+ }, 2000)
+ if not res then
+ ngx.say("err: ", err)
+ return
+ end
+ ngx.say("status: ", res.status)
+ ngx.say("content-type: ", res.headers["Content-Type"])
+
+ local reader = res.body_reader
+ if not reader then
+ ngx.say("no body_reader")
+ return
+ end
+ local buf = {}
+ while true do
+ local chunk, rerr = reader(4096)
+ if rerr then
+ ngx.say("read err: ", rerr)
+ break
+ end
+ if not chunk then
+ break
+ end
+ buf[#buf + 1] = chunk
+ end
+ local body = table.concat(buf)
+ local n = select(2, body:gsub("data: ", ""))
+ ngx.say("sse events: ", n)
+ ngx.say("saw done: ", body:find("[DONE]", 1, true) ~= nil)
+ }
+ }
+--- response_body
+status: 200
+content-type: text/event-stream
+sse events: 4
+saw done: true
+--- no_error_log
+[error]
+
+
+
+=== TEST 18: the C client reuses a pooled connection across requests
+--- skip_eval: 3: system((($ENV{TEST_NGINX_BINARY} || "nginx") . " -V 2>&1 |
grep -q ngx_http_ffi_client")) != 0
+--- config
+ location = /mock-keepalive {
+ # counts requests served on this connection: 1,2,3 proves one
+ # pooled connection carried all three
+ content_by_lua_block { ngx.print("req ", ngx.var.connection_requests) }
+ }
+
+ location /t {
+ content_by_lua_block {
+ local transport = require("apisix.plugins.ai-transport.http")
+ for i = 1, 3 do
+ local res, err = transport.request({
+ method = "POST",
+ scheme = "http",
+ host = "127.0.0.1",
+ port = 1984,
+ path = "/mock-keepalive",
+ headers = {["content-type"] = "application/json"},
+ body = {model = "m"},
+ }, 2000)
+ if not res then
+ ngx.say(i, ": err ", err)
+ return
+ end
+ local body = res:read_body()
+ transport.set_keepalive(res, 60000, 30)
+ ngx.say(i, ": ", res.status, " ", body)
+ end
+ }
+ }
+--- response_body
+1: 200 req 1
+2: 200 req 2
+3: 200 req 3
+--- no_error_log
+[error]
+
+
+
+=== TEST 19: the C client reaches an upstream named by hostname
+--- skip_eval: 3: system((($ENV{TEST_NGINX_BINARY} || "nginx") . " -V 2>&1 |
grep -q ngx_http_ffi_client")) != 0
+--- config
+ location = /mock-host {
+ content_by_lua_block {
+ ngx.print("host header: ", ngx.var.http_host)
+ }
+ }
+
+ location /t {
+ content_by_lua_block {
+ -- "localhost" only resolves through core.resolver, so this proves
+ -- the real client is going through the resolver it was given
+ local transport = require("apisix.plugins.ai-transport.http")
+ local res, err = transport.request({
+ method = "POST",
+ scheme = "http",
+ host = "localhost",
+ port = 1984,
+ path = "/mock-host",
+ headers = {["content-type"] = "application/json"},
+ body = {model = "m"},
+ }, 2000)
+ if not res then
+ ngx.say("err: ", err)
+ return
+ end
+ ngx.say("status: ", res.status)
+ ngx.say(res:read_body())
+ }
+ }
+--- response_body
+status: 200
+host header: localhost:1984
+--- no_error_log
+[error]
+
+
+
+=== TEST 20: the C client verifies TLS against the configured trust store
+--- skip_eval: 3: system((($ENV{TEST_NGINX_BINARY} || "nginx") . " -V 2>&1 |
grep -q ngx_http_ffi_client")) != 0
+--- http_config
+ server {
+ listen 21981 ssl;
+ ssl_certificate cert/apisix.crt;
+ ssl_certificate_key cert/apisix.key;
+ server_name test.com;
+ location = /mock-tls {
+ content_by_lua_block { ngx.print("tls ok") }
+ }
+ }
+--- config
+ location /t {
+ content_by_lua_block {
+ -- no per-call CA: the trust store comes from
+ -- lua_ssl_trusted_certificate, as it does for every cosocket
+ local transport = require("apisix.plugins.ai-transport.http")
+ local res, err = transport.request({
+ method = "POST",
+ scheme = "https",
+ host = "127.0.0.1",
+ port = 21981,
+ path = "/mock-tls",
+ ssl_verify = true,
+ ssl_server_name = "test.com",
+ headers = {["content-type"] = "application/json"},
+ body = {model = "m"},
+ }, 2000)
+ if not res then
+ ngx.say("err: ", err)
+ return
+ end
+ ngx.say("status: ", res.status)
+ ngx.say(res:read_body())
+ }
+ }
+--- response_body
+status: 200
+tls ok
+--- no_error_log
+[error]
+
+
+
+=== TEST 21: a client that cannot take a resolver fails the request
+--- config
+ location /t {
+ content_by_lua_block {
+ local orig_ffi = package.loaded["resty.ngx_http_ffi_client"]
+ local orig_utils = package.loaded["apisix.utils.http"]
+
+ -- a runtime older than the pinned one: the module is there, but it
+ -- resolves names by nginx's rules rather than the gateway's
+ package.loaded["resty.ngx_http_ffi_client"] = {
+ new = function()
+ return {}
+ end,
+ }
+
+ package.loaded["apisix.utils.http"] = nil
+ local http_client = require("apisix.utils.http")
+ for _ = 1, 2 do
+ local client, err = http_client.new(http_client.FFI_CLIENT)
+ ngx.say("client: ", client and "created" or err)
+ end
+
+ package.loaded["resty.ngx_http_ffi_client"] = orig_ffi
+ package.loaded["apisix.utils.http"] = orig_utils
+ }
+ }
+--- response_body
+client: resty.ngx_http_ffi_client does not support set_resolver, the runtime
is older than the pinned one
+client: resty.ngx_http_ffi_client does not support set_resolver, the runtime
is older than the pinned one
+--- error_log
+does not support set_resolver