Skip to content

feat(ai-proxy): send LLM requests through ngx_http_ffi_client - #13778

Merged
shreemaan-abhishek merged 14 commits into
apache:masterfrom
shreemaan-abhishek:feat/ai-transport-ffi-client
Aug 18, 2026
Merged

shreemaan-abhishek merged 14 commits into
apache:masterfrom
shreemaan-abhishek:feat/ai-transport-ffi-client

Conversation

@shreemaan-abhishek

@shreemaan-abhishek shreemaan-abhishek commented Aug 5, 2026 •

Copy link
Copy Markdown
Contributor

Description

Why. ai-proxy, ai-proxy-multi and ai-request-rewrite all send their
outbound LLM requests through apisix/plugins/ai-transport/http.lua. That path
runs on every request the AI gateway proxies, and lua-resty-http spends
roughly three times the outbound CPU time of ngx_http_ffi_client, the HTTP
client implemented as an nginx C module.

How. plugin_attr.ai-proxy.http_client names the client:
ngx_http_ffi_client, the default, or lua-resty-http. The name is validated
against an enum schema and mapped to a module, the module is loaded on the
first request, and the result is cached only once a client has actually loaded.

The two clients share an object API (new, set_timeout, connect,
request, res.body_reader, res:read_body, set_keepalive, close), so
the change is confined to which client the transport constructs; the providers
and the streaming path are untouched.

A client that is missing or cannot be created fails the request and the error
names the cause. The transport never falls back to the other client, so what
runs is always what the config asked for.

Name resolution. Cosockets have their hostnames 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 left alone it would see only nginx's
resolver and resolve names by different rules than the rest of the gateway.
It takes a resolver as of v0.1.3 (api7/ngx_http_ffi_client#45), so the client
is handed core.resolver.parse_domain once, when the module loads. The client
resolves before it derives its connection-pool key and keeps the name for the
Host header and the SNI. A client too old to take a resolver fails the
request and names that as the cause, rather than silently resolving by its own
rules.

Runtime

APISIX_RUNTIME moves to 1.3.16, built with ngx_http_ffi_client
v0.1.3 (api7/apisix-build-tools#488, released as apisix-runtime/1.3.16;
the tag is that PR's merge commit). ci/linux-install-openresty.sh picks up the
new debug-deb checksums for both architectures.

Why this client revision. 1.3.13 and 1.3.14 pin v0.1.1, which is v0.1.0 plus a
LICENSE, a README section and a config tweak, with byte-identical C sources.
The releases that matter start at v0.1.2, and v0.1.3 is the first to carry all
four of the defects and gaps this cutover surfaced:

fix issue without it
request bodies containing CR or LF are no longer rejected api7/ngx_http_ffi_client#38 any caller posting pretty-printed JSON gets a 500
connection errors respect lua_socket_log_errors api7/ngx_http_ffi_client#39 every refused upstream writes an [error] line
trust store falls back to lua_ssl_trusted_certificate api7/ngx_http_ffi_client#42 every verified TLS connection fails, so no HTTPS provider is reachable
names resolve through a resolver the host application installs api7/ngx_http_ffi_client#45 the client sees only nginx's resolver, so the gateway's own resolution rules never apply to it

To check what a given build actually contains:

nginx -V 2>&1 | tr ' ' '\n' | grep ffi_client

which prints the --add-module=.../ngx_http_ffi_client-<tag> path. On the
published 1.3.16 packages that reads ngx_http_ffi_client-v0.1.3.

v0.1.3 is v0.1.2 plus the resolver hook, and that hook is a change to the Lua
binding alone: the C sources are byte-identical between the two tags.

Performance record for the client:
https://github.com/api7/ngx_http_ffi_client/blob/63771541c5a229da8a840ab6008ba3771a22fb71/benchmark/results-full.md

A hand-built runtime without the module has to set
http_client: lua-resty-http, which runs exactly the code that ran before.

Which issue(s) this PR fixes:

N/A

Checklist

  • I have explained the need for this PR and the problem it solves
  • I have explained the changes or the new features added to this PR
  • I have added tests corresponding to this change
  • I have updated the documentation to reflect this change
  • I have verified that this change is backward compatible (If not, please discuss on the APISIX mailing list first)

Test plan

t/plugin/ai-transport-http.t covers client selection with stubs, and then
drives the real C client against local upstreams so module loading, the FFI
boundary and the connection lifecycle are exercised rather than mocked:

case what it proves
TEST 8 the C client is the default
TEST 9 a runtime whose C module is not built in fails the request, twice in a row, instead of switching clients
TEST 12 a module that loads but is not a table fails the request
TEST 15 a module whose loader raises fails the request, covering the pcall(require, ...) failure path
TEST 13 an unrecognised http_client fails schema validation
TEST 10, 11 plugin_attr selects lua-resty-http, and that path runs against a real upstream with the C client stubbed to a sentinel so a selection regression cannot pass silently
TEST 14 the client is given core.resolver.parse_domain, the gateway's own resolver
TEST 21 a client too old to take a resolver fails the request, twice in a row
TEST 16 real C client, buffered request and response body against a local upstream
TEST 17 real C client, an SSE response read to completion through body_reader
TEST 18 real C client, three requests over one pooled connection, asserted via the upstream's connection_requests counter
TEST 19 real C client to an upstream named localhost, which only resolves through core.resolver, with the name kept in the Host header
TEST 20 real C client over TLS with ssl_verify on and no per-call CA, verifying against lua_ssl_trusted_certificate

TESTs 16-20 carry
--- skip_eval: 2: !-f "/usr/local/openresty/lualib/resty/ngx_http_ffi_client.lua"
so they run wherever the module is installed, which includes CI on 1.3.16, and
skip on a runtime without it rather than failing for the wrong reason.

Beyond the suite, this exact code was run on a gateway carrying the pinned
client against real Azure OpenAI, comparing both clients on the same
routes:

scenario ngx_http_ffi_client lua-resty-http
buffered chat (gpt-4o) 200 200
streaming SSE 15 chunks, [DONE] 15 chunks, [DONE]
multiline JSON body 200 200
failover 5xx to a healthy instance 200 200
keepalive, 5 sequential 5/5 5/5
responses API (gpt-5-codex) 200 200
anthropic messages (claude-sonnet-4-5) 200 200
36 KB request body 200 200

That covers three protocol adapters (openai-chat, openai-responses,
anthropic-messages), TLS with SNI, streaming, retry and connection reuse.

The resolver hook was re-run against the same three providers. Two extra routes
went to upstreams named only in /etc/hosts, which nginx's resolver cannot
answer for: both returned 200 with the name intact in the Host header the
upstream received, and both failed with <name> could not be resolved once the
set_resolver call was removed, which is what pins the behaviour on the hook.
The three Azure hostnames happen to share one address, so the interleaved runs
also cover pooling across SNIs on a single peer: ssl_server_name is part of
the client's pool key, so they never share a connection.
Reassembled streaming text is identical on both clients; only the raw byte count
differs, and it varies run-to-run within each client, so that is provider
variance rather than client behaviour.

ai-proxy, ai-proxy-multi and ai-request-rewrite share ai-transport/http.lua
for every outbound LLM request. It now prefers ngx_http_ffi_client, a C client
whose object API matches lua-resty-http and which costs about a third of the
outbound CPU time.

The module only exists when the APISIX runtime was built with it, so the
transport resolves the client once per worker and keeps lua-resty-http as the
fallback. plugin_attr.ai-proxy.http_client pins the choice: auto (default),
ffi, or lua-resty-http.

Connection and Transfer-Encoding are now dropped from the forwarded headers.
They describe the downstream connection, and passing them on desyncs the
upstream one: the client frames the request body with Content-Length itself,
and a forwarded "Connection: close" would also keep the connection out of the
keepalive pool.
@dosubot dosubot Bot added size:L This PR changes 100-499 lines, ignoring generated files. enhancement New feature or request labels Aug 5, 2026
Dropping Connection and Transfer-Encoding from the headers forwarded to the LLM
upstream broke an existing ai-proxy-multi retry case: after a stream that dies
before its first byte, the retry stopped finding an instance to re-pick and the
request returned 502.

Forwarding a downstream Connection header to a third-party upstream is still
wrong, and the streaming read-error path still leaks the upstream connection
without closing it, but neither belongs in a change about which HTTP client the
transport builds.
…p_ffi_client

plugin_attr.ai-proxy.http_client took auto, ffi or lua-resty-http, and auto
made the choice implicit in what the runtime happened to carry. It now takes
one of the two client names, ngx_http_ffi_client or lua-resty-http, and
defaults to the first.

A runtime built without the module still falls back rather than failing the
request, and says so at warn level. Error level would put a line in the log for
every AI request on such a runtime.

t/plugin/ai-transport-http.t TEST 11 drives the lua-resty-http path against a
real upstream with nothing stubbed, so the path that config selects is covered
end to end rather than only through a stub.
…ing back

Resolve the client from plugin_attr.ai-proxy.http_client through a name to
module map, validate the name against an enum schema, and cache it only once
it has loaded. A missing or unusable client is now an error the request
carries, never a silent switch to the other one.

Bump APISIX_RUNTIME to 1.3.12, the first runtime built with
ngx_http_ffi_client.
Cosockets get their hostnames resolved by apisix/patch.lua, which routes
them through core.resolver and so honours dns_resolver, /etc/hosts and the
search domains. ngx_http_ffi_client dials from C and never touches a
cosocket, so it saw only nginx's `resolver` and failed on every name that
layer cannot answer.

The transport now resolves the name the same way before handing the address
to the C client, keeping the original for the Host header and the SNI.

Pin the body-encoding and error-mapping cases to lua-resty-http: they stub
resty.http, so under the C-client default they were driving the real client
at a dead port instead of the stub.
@dosubot dosubot Bot added size:XL This PR changes 500-999 lines, ignoring generated files. and removed size:L This PR changes 100-499 lines, ignoring generated files. labels Aug 6, 2026
Comment thread apisix/plugins/ai-transport/http.lua Outdated
-- core.resolver, which honours dns_resolver, /etc/hosts and the search
-- domains. The C client dials on its own and only sees nginx's `resolver`,
-- so the name is resolved here and kept for the Host header and the SNI.
local function resolve_upstream_host(params)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ngx_http_ffi_client dials from C, so it never touches the cosocket that patch.lua wraps — it only sees nginx's resolver, which does not read /etc/hosts or our dns_resolver config. That is why every localhost upstream 500'd. Resolving here puts it back on the same path as every other socket in the gateway, and we keep the name for Host and SNI.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could ngx_http_ffi_client provide a hook for DNS resolution? Otherwise, the DNS‑resolving code would have to be duplicated across many plugins.

@membphis membphis left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Please address these blockers before merge:

  1. The current t/plugin/[a-k]*.t CI shard is failing on this head, and it contains the changed t/plugin/ai-transport-http.t coverage. Please identify the cause and make the relevant checks pass on this exact head.

  2. Please make the ngx_http_ffi_client revision bundled in apisix-runtime 1.3.12 traceable, and add APISIX-level coverage that runs the real C client through buffered responses, SSE streaming, TLS/DNS, and keepalive. The new tests mostly stub the module, so they do not prove that the new default client includes and preserves the required framing, connection-lifecycle, and error-semantics fixes.

  3. The linux_apisix_current_luarocks_in_customed_nginx job is also failing. Because custom runtimes may not include the C module, please verify the supported compatibility path on this head: either include the module or explicitly configure and test lua-resty-http as the fallback for that build.

1.3.15 is the first runtime built with ngx_http_ffi_client v0.1.2, which
carries the three fixes this change depends on: request bodies containing
CR or LF are no longer rejected, connection errors respect
lua_socket_log_errors, and the trust store falls back to
lua_ssl_trusted_certificate so verified TLS works on a bundled OpenSSL.
The selection tests stub resty.ngx_http_ffi_client, so module loading, the
FFI boundary and the connection lifecycle were never exercised. Add cases
that run the real client against local upstreams: a buffered request, an SSE
response read to completion through body_reader, three requests over one
pooled connection (asserted with the upstream's connection_requests counter),
a hostname upstream that only resolves through core.resolver, and TLS with
ssl_verify on and no per-call CA. They skip where the module is absent rather
than failing for the wrong reason.

Also cover a genuine require() failure: assigning a string to package.loaded
makes require() succeed, so the old case only reached the non-table branch.
A package.preload loader that raises reaches the pcall(require) failure path.
Comment thread apisix/plugins/ai-transport/http.lua Outdated
The skip guard checked a hardcoded /usr/local/openresty path, which is not
necessarily the runtime under test, and it checked the Lua binding rather
than whether the module is compiled in. Ask the binary instead, falling back
to bare nginx the way both CIs resolve it (neither sets TEST_NGINX_BINARY;
both prepend $OPENRESTY_PREFIX/nginx/sbin to PATH).

Also correct the skip count from 2 to 3: Test::Nginx adds a status-code
assertion alongside response_body and no_error_log. The file now reports the
same subtest total whether these blocks run or skip.
The selection, validation and DNS-parity logic sat inside the AI transport,
so forward-auth, http-logger and anything else moving off lua-resty-http
could not reuse it. It now lives in apisix/utils/http.lua alongside the other
shared client factories: the module owns the client names, the schema
fragment, loading and caching, and resolve_upstream_host(); the caller owns
where the preference comes from and passes the name in.

No behaviour change: the transport still reads
plugin_attr.ai-proxy.http_client and the whole suite is unchanged.
t/sse_server_example/sse_server_example is the Go binary produced by
ci/common.sh's start_sse_server_example; it is built at test time and must
not be tracked.
membphis
membphis previously approved these changes Aug 10, 2026

@membphis membphis left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

AlinsRan
AlinsRan previously approved these changes Aug 11, 2026
Ships ngx_http_ffi_client v0.1.3.
@shreemaan-abhishek
shreemaan-abhishek dismissed stale reviews from AlinsRan and membphis via 93dc395 August 13, 2026 12:06
The C client takes a resolver as of ngx_http_ffi_client v0.1.3, so the
transport hands it core.resolver.parse_domain once at load instead of
resolving each request itself.
…fi-client

# Conflicts:
#	.requirements
#	ci/linux-install-openresty.sh

@membphis membphis left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@shreemaan-abhishek
shreemaan-abhishek merged commit 7ea42d4 into apache:master Aug 18, 2026
19 checks passed
@kayx23 kayx23 mentioned this pull request Aug 18, 2026
5 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request size:XL This PR changes 500-999 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants