Skip to content

refactor(ai-providers): make the LLM request client ctx-free#13699

Open
AlinsRan wants to merge 6 commits into
apache:masterfrom
AlinsRan:fix/ai-transport-ctx-free
Open

refactor(ai-providers): make the LLM request client ctx-free#13699
AlinsRan wants to merge 6 commits into
apache:masterfrom
AlinsRan:fix/ai-transport-ctx-free

Conversation

@AlinsRan

@AlinsRan AlinsRan commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Description

ai-providers/base.lua's build_request / request took the request ctx and reached into the downstream request through it. That coupling was wrong in two ways:

  1. It leaked client headers. construct_forward_headers pulled core.request.headers(ctx) and always forwarded them, so ai-request-rewrite — which calls an LLM to rewrite the body — sent the client's Authorization/Cookie to that third-party endpoint.
  2. It made a self-contained LLM call impossible without faking a ctx. None of what ctx carried is meaningful for an internal call, so callers had to pass a throwaway ctx; an auth.gcp config then blew up on plugin_ctx_id(fake ctx).

Drop the ctx parameter. Everything derived from the downstream request or from request state is now resolved by the caller and passed via opts:

opts was
target_protocol ctx.ai_target_protocol
header_transform ctx.ai_converter.convert_headers
access_token fetch_gcp_access_token(ctx, ...) (ctx keyed its cache)
method / client_args core.request.get_method() / ctx.var.args (passthrough)
raw_request_body ctx.ai_raw_request_body / core.request.get_body()
client_headers core.request.headers(ctx) inside the transport

Protocol conversion moves to the caller as well: converters stash state on ctx for the response side to read back, so ai-proxy converts and passes the converted body. ai-proxy also still reads ctx.ai_request_body_changed — the cross-plugin "an earlier plugin rewrote the body" signal — and simply withholds raw_request_body then, which preserves the raw-body reuse optimisation (#1597) without the transport knowing about ctx.

_M.request only ever forwarded ctx to build_request, so it is ctx-free too — a genuine pure client for internal callers.

Also: redact_extra_opts() now strips client_headers, so the forwarded headers don't reach the info-level request extra_opts log line.

Scope: request side only. parse_response/parse_streaming_response keep ctx by design — there it is an output sink (ngx.status, ctx.var.llm_*) and the downstream plugin chain (lua_response_filter runs each plugin's lua_body_filter off api_ctx.plugins). Internal callers never reach that path; they take (status, raw_body) from _M.request and parse it themselves.

Tests

New t/plugin/ai-transport-header-forwarding.t: the internal rewrite call reaches the LLM with its own credentials and no client Cookie, while the transparent proxy path still forwards it; the client's secret never appears in any log. Both assertions fail against the pre-fix code.

Existing suites covering the moved logic all pass: ai-proxy-request-body-override (raw-body reuse), ai-proxy-protocol-conversion (converter), ai-proxy-passthrough (method/query), ai-proxy-bedrock (SigV4 signs the finalized body last), ai-proxy-vertex-ai (GCP token). build_request unit tests updated to the new signature.

The HTTP transport's construct_forward_headers pulled the downstream
request's headers straight from ctx and always forwarded them. That is
correct for the transparent proxy path (ai-proxy / ai-proxy-multi) but
wrong for plugins that make a self-contained internal LLM call:
ai-request-rewrite forwarded the client's Authorization / Cookie to the
third-party LLM endpoint used to rewrite the body — a header leak.

Make the transport ctx-free: construct_forward_headers now takes an
explicit client_headers table (nil for internal requests) instead of
reading core.request.headers(ctx). build_request forwards opts.client_headers,
and only the proxy path (ai-proxy/base.lua) sets it. Internal callers
(ai-request-rewrite, and any future embeddings/RAG/moderation call) pass
none, so their own credentials — not the client's — reach the LLM.

Add a test asserting the internal rewrite call receives no client Cookie
while the transparent proxy path still forwards it.
@dosubot dosubot Bot added size:L This PR changes 100-499 lines, ignoring generated files. bug Something isn't working labels Jul 16, 2026
AlinsRan added 2 commits July 16, 2026 15:30
The proxy path now carries the client's request headers in extra_opts
(client_headers) so ai-proxy can forward them. But build_request logs
extra_opts at info level via redact_extra_opts(), which only stripped
auth — so the client's Cookie/Authorization would be written to the log.
Strip client_headers in redact_extra_opts too, closing the log-side leak
that mirrors the network-side one.

Extend the test: the mock LLM now also records Authorization; assert the
internal rewrite call reaches the LLM with its own credentials (not the
client's) and that the client's secret never appears in any log, including
the extra_opts info line, on the proxy path.
build_request/request took the request ctx and reached into the downstream
request through it, so an internal caller had to fake a ctx to use them.
None of what ctx carried is meaningful for a self-contained LLM call.

Drop the ctx parameter. Everything derived from the downstream request or
from request state is now resolved by the caller and passed via opts:

  target_protocol   was ctx.ai_target_protocol
  header_transform  was ctx.ai_converter.convert_headers
  access_token      was fetch_gcp_access_token(ctx, ...) (ctx keyed its cache)
  method/client_args was core.request.get_method() / ctx.var.args (passthrough)
  raw_request_body  was ctx.ai_raw_request_body / core.request.get_body()
  client_headers    (already)

Protocol conversion moves to the caller too: converters stash state on ctx
for the response side to read back, so ai-proxy converts and passes the
converted body. ai-proxy also keeps reading ctx.ai_request_body_changed --
the cross-plugin 'an earlier plugin rewrote the body' signal -- and simply
withholds raw_request_body then, which preserves the raw-body reuse
optimisation without the transport knowing about ctx.

ai-request-rewrite now calls the provider as a plain client and resolves its
own GCP token; it sets ai_request_body_changed where it actually rewrites the
body, for later plugins rather than for its own call.

_M.request only ever forwarded ctx to build_request, so it is now ctx-free
too: a genuine pure client for internal callers.
@AlinsRan AlinsRan changed the title fix(ai-transport): stop internal LLM calls leaking client headers refactor(ai-providers): make the LLM request client ctx-free Jul 17, 2026
AlinsRan added 3 commits July 17, 2026 14:49
The new build_request test block broke the blank-line spacing utils/reindex
enforces; run it and take its renumbering (the file's existing 3a/4b/5c
convention continues as 6d).
…ient

The ctx removal replaced one opaque parameter with four flat fields
(client_headers, client_args, method, raw_request_body) scattered through an
already-large opts table. That named no concept, let a caller half-set the
group, and made redaction a matter of remembering each field -- which had
already gone wrong: opts.raw_request_body put the client's verbatim body (the
user's prompt) into the info-level 'request extra_opts' log line, since
redact_extra_opts only stripped auth and client_headers.

Group them under a single opts.client:

    client = { headers, args, method, raw_body }

Its presence is now what marks a call as proxying an inbound request; an
internal call omits it entirely, so nothing of the client's can reach the LLM
or the logs. redact_extra_opts drops the one key and with it every piece of
downstream data, instead of enumerating fields.

Extend the proxy test to assert the client's prompt never appears in any log;
it fails without the redaction.
…quest

build_request did three jobs at once: shape the body for the target protocol,
assemble the HTTP request, and sign it. The seam between them followed an
implementation detail rather than a concept -- it knew about target_protocol and
mutated the body in four places, yet protocol *conversion* sat outside, purely
because that one function takes ctx. A reader could not state what the function's
job was.

Every protocol-driven decision in there turned out to be a body decision: nothing
protocol-shaped touched the URL, headers, method or signing. So split by what each
layer produces:

  build_body(request_body, opts) -> body, changed
      prepare_outgoing_request / model_options / llm_options(capability)
      / request_body_override_map / remove_model.  This is where the target
      protocol matters.

  build_request(conf, body, opts) -> params
      endpoint, auth, headers, method, query, SigV4 last.  Protocol-agnostic;
      the body is passed through as given.

  request(conf, request_table, opts) = build_body + build_request + send

The raw-body reuse rule goes with it. The transport already sends a string body
verbatim and encodes a table, so 'reuse the client's bytes' is just the caller
passing the string -- an explicit line at the call site instead of an implicit
contract (client.raw_body plus an internal body_changed) that the caller had to
know build_request's internals to use correctly. opts.client now carries only
downstream facts: headers, args, method.

Retarget the build_request unit tests at the new contract (pass-through) and add
one for build_body's changed flag, which is what the reuse decision hangs on.
@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 Jul 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working size:XL This PR changes 500-999 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant