Skip to content

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

Open
shreemaan-abhishek wants to merge 6 commits into
apache:masterfrom
shreemaan-abhishek:feat/ai-transport-ffi-client
Open

feat(ai-proxy): send LLM requests through ngx_http_ffi_client#13778
shreemaan-abhishek wants to merge 6 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

ai-proxy, ai-proxy-multi and ai-request-rewrite all send their outbound
LLM requests through apisix/plugins/ai-transport/http.lua. This moves that
transport to ngx_http_ffi_client, an HTTP client implemented as an nginx C
module.

ngx_http_ffi_client exposes the same object API as lua-resty-http
(new, set_timeout, connect, request, res.body_reader,
res:read_body, set_keepalive, close) and does the HTTP/1.1 framing in C,
which costs roughly a third of the outbound CPU time on this path. Measurements:
https://github.com/api7/ngx_http_ffi_client/blob/63771541c5a229da8a840ab6008ba3771a22fb71/benchmark/results-full.md

plugin_attr.ai-proxy.http_client names the client:

plugin_attr:
  ai-proxy:
    http_client: ngx_http_ffi_client # 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. A client that is missing or cannot be created fails the request
and the error names the cause; the transport never substitutes the other client,
so what runs is always what the config asked for.

APISIX_RUNTIME moves to 1.3.12, the first runtime built with the module. A
hand-built runtime without it 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 gains six cases covering client selection: the C
client is the default, a runtime whose C module is not built in fails the
request instead of switching clients, a runtime where the module does not load
at all fails the same way, an unrecognised http_client value fails schema
validation, plugin_attr selects lua-resty-http over an available C client,
and the lua-resty-http path is driven against a real upstream with the C
client stubbed to a sentinel so a regression in the selection cannot pass
silently.

Beyond the plugin tests, the client itself was exercised against a real build:
an OpenResty carrying the module, driven through the exact call sequence this
transport uses, covering a buffered request, an SSE stream read through
body_reader, set_keepalive, close, and the error strings handle_error
maps to 502/504.

The published apisix-runtime/1.3.12 packages were checked directly: the
nginx binary carries the module's symbols and the package installs
lualib/resty/ngx_http_ffi_client.lua.

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
-- 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.

@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.

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.

2 participants