Skip to content

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

Open
AlinsRan wants to merge 12 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 12 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).

So: drop the ctx parameter, and while the seams are open, cut them where the concepts actually are.

The client is now three layers, split by what each produces

provider:build_body(request_body, opts)  -> body, changed   -- shape the body for the target protocol
provider:build_request(conf, body, opts) -> params          -- assemble the HTTP request, sign last
provider:request(conf, request_table, opts) -> status, raw_body   -- = build_body + build_request + send

build_request used to do all three jobs at once. Its seam 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 build_body owns the protocol, and build_request no longer knows protocols exist.

What the caller now supplies

Everything taken from the downstream request is grouped under one key — its presence is what marks a call as proxying an inbound request:

opts.client = { headers, args, method }   -- proxy path only; internal calls omit it entirely
opts was
client.headers core.request.headers(ctx) inside the transport
client.method / client.args core.request.get_method() / ctx.var.args (passthrough)
target_protocol ctx.ai_target_protocol
header_transform ctx.ai_converter.convert_headers
access_token fetch_gcp_access_token(ctx, ...) (ctx only keyed its cache)

Protocol conversion stays with the caller: converters stash state on ctx for the response side to read back, so ai-proxy converts and hands over the converted body.

The raw-body reuse optimisation (#1597) is preserved but no longer implicit. 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 — one visible line at the call site instead of a contract (raw_request_body plus an internal body_changed) that the caller had to know build_request's internals to use correctly.

Call flow

Proxy path

client request
└─ nginx access phase  (apisix/init.lua)
   ├─ common_phase("access") → ai-proxy-multi._M.access
   │  ├─ base.detect_request_type(ctx)        → ctx.ai_client_protocol
   │  └─ pick_ai_instance()                   → ctx.picked_ai_instance
   │
   └─ common_phase("before_proxy") → ai-proxy/base.lua before_proxy
      ├─ extra_opts{ endpoint, auth, target_*, model_options, override_* }
      ├─ extra_opts.client = { headers, args, method }     ← downstream facts
      ├─ gcp                → extra_opts.access_token
      ├─ converter.convert_request(body, ctx)              ← conversion (stateful → caller)
      └─ do_request()
         ├─ provider:build_body(body, opts)  -> body, shaped     ← protocol shaping (pure)
         ├─ if not shaped and not converted and not ctx.ai_request_body_changed
         │     then body = core.request.get_body() end           ← reuse client bytes (explicit)
         ├─ provider:build_request(conf, body, opts) -> params   ← HTTP + SigV4 (pure)
         ├─ transport_http.request(params, conf.timeout)
         └─ parse_response / parse_streaming_response(ctx, …)    ← response side keeps ctx

Internal call (ai-request-rewrite)

ai-request-rewrite._M.access(conf, ctx)
├─ proto.build_simple_request(prompt, client_body)     → its own { messages }
├─ gcp → extra_opts.access_token
├─ provider:request(conf, request_table, extra_opts)   ← no ctx, no opts.client
│  ├─ build_body(request_table, opts)
│  ├─ build_request(conf, body, opts)
│  └─ transport_http.request() → (status, raw_body)    ← never touches parse_response
├─ proto.extract_response_text()
└─ ngx.req.set_body_data(content); ctx.ai_request_body_changed = true
      └─► later ai-proxy sees the flag → does not reuse the client's raw bytes

ctx.ai_request_body_changed stays: it is the cross-plugin "an earlier plugin rewrote the body" signal that the raw-body reuse optimisation depends on. It is now read by ai-proxy — which legitimately holds ctx — rather than by the transport.

Logging

redact_extra_opts() drops the whole client key, so the downstream headers (Authorization/Cookie), query args and verbatim body (the user's prompt) never reach the info-level request extra_opts log line. Dropping one key drops all of it, instead of enumerating fields.

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) and parse it themselves.

Still deliberately left in place: opts.header_transform. convert_headers must run before SigV4 signs, and signing is last inside build_request — exposing sign() to callers so the hook could move out would trade a small wart for "someone forgets to sign".

Tests

New t/plugin/ai-transport-header-forwarding.t: the internal rewrite call reaches the LLM with its own credentials and no client Cookie; the transparent proxy path still forwards it; the client's Authorization and prompt never appear in any log. Each assertion fails against the pre-fix code.

build_request unit tests retargeted at the new contract (body passed through untouched, string sent verbatim), plus a new one for build_body's changed flag — the flag the reuse decision hangs on.

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), ai-proxy-multi.balancer.

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
AlinsRan added 5 commits July 20, 2026 14:40
….client

Review follow-ups.

fetch_gcp_access_token took a ctx only to build its cache key via
plugin_ctx_id(ctx, name) -- i.e. route id + conf version, which says nothing
about the token. Moving the call out to each caller (so the provider could be
ctx-free) therefore bought nothing and duplicated the same block in every plugin
that supports a gcp provider. Key it on the credentials instead: the token
depends only on the service account, so identical credentials now share one
token across routes and the lookup needs no ctx. The call moves back inside
build_request and no caller repeats it. The key keeps its '<...>#<name>' shape,
which the cache-hit test asserts on.

Rename opts.client to opts.downstream: it is not a client object, it is the data
taken from the inbound request (headers, args, method). The grouping stays --
its presence is what marks a call as proxying, and redact_extra_opts drops the
one key to keep all of it out of the logs -- but the name no longer suggests an
HTTP client.

Keep protocol conversion inside the pcall boundary. Moving it out of do_request
took it outside the pcall that bounds a request attempt, which is reachable with
input-controlled but valid JSON: an Anthropic image block whose "source" is a
boolean makes convert_media_block index a boolean, and the exception then escapes
before_proxy instead of becoming a controlled 500. Run it inside do_request
again, keeping its returned-400 path, and add a regression test driving exactly
that payload.

Assert both build_request calls in the auth.query mutation test; a build failure
would otherwise leave auth_query untouched and still report OK.
The nesting was justified on two grounds; only one held up.

It did not make the group atomic: build_request reads each field with its own
nil check (`downstream and downstream.headers`, `... and downstream.method`),
so a caller could always set some and not others. The nesting made them look
like one unit without making them behave as one.

What it did buy was redaction: one `redacted.downstream = nil` kept every
client-derived field out of the logs, and a field added later was covered for
free. Keep that property without the nesting by naming the fields in
log-sanitize instead -- CLIENT_DERIVED_FIELDS is an explicit list that says out
loud which options are sensitive, which reads better than an implicit 'anything
under this key'. The header-forwarding test covers it: drop a field from the
list and it fails.

opts.client_headers / client_args / client_method now sit directly on opts,
which is what they are -- static options taken from the inbound request, not a
client object.
The denylist only protected the fields someone remembered to name. That is the
failure mode that already happened once in this branch: raw_request_body was
added to extra_opts and the user's prompt went into the info log, because the
sanitizer stripped only auth. Flattening opts.client_* back onto opts made that
worse -- the nested form at least covered every field under one key.

Invert it: name the fields that are safe to log (connection/routing shape and
non-secret provider config) and drop everything else. A field added to
extra_opts later is now redacted by default rather than leaked by default.
redact_params right below already works this way; this makes the two
consistent.

Also drops the per-request deepcopy -- the allowlist copies a dozen scalars
instead.
Neither list was the right answer. A denylist leaked whatever nobody remembered
to name -- it missed client_headers and then raw_request_body inside this branch
alone. An allowlist fixed the default but made log-sanitize track the caller's
whole field vocabulary: which options exist is the caller's business, only what
counts as a secret is the sanitizer's.

The real problem was serialising an open-ended options bag into a log line at
all. Most of what it printed is already logged from the resolved `params`
further down (endpoint, path, host, headers -- the last with per-header
redaction), so the dump was largely duplicate. Log the three things it uniquely
carried -- instance name, target protocol, model -- by name.

redact_extra_opts has no callers left and goes with it; log-sanitize is back to
one job, redacting the params it is handed. A field added to extra_opts later is
now private by default with no list to maintain.
The security fix is the build_request log-site change -- it stopped dumping the
whole options bag and logs three named values (instance, target_protocol,
model) instead, which is what closed the leak. Deleting redact_extra_opts on
top of that bought nothing: it is a small, tested, exported util in a shared
module, its removal only enlarged a security diff, dropped a used require, and
forced deleting a passing test. It also left HEAD internally inconsistent -- the
function gone but t/utils/log-sanitize.t still calling it -- which would break
CI.

Restore log-sanitize.lua and t/utils/log-sanitize.t to their master state. The
log-site change stands on its own.
@membphis

Copy link
Copy Markdown
Member

[P2] GCP token cache key can alias different credentials and TTL policies

The new cache key uses ngx.crc32_long(service_account_json) .. "#" .. name. Because it relies on a 32-bit CRC and omits expire_early_secs and max_ttl, separate configurations can alias: different credentials may reuse the wrong cached access token after a collision, while identical credentials and names with different TTL policies can bypass the latter policy.

Please use a collision-resistant digest of the resolved credentials and all fields that affect cache reuse, without logging plaintext secrets, and add coverage for same-name configurations with different TTL policies.

The ctx-free refactor keyed the GCP access-token cache on
ngx.crc32_long(service_account_json) .. name. A 32-bit CRC can collide, so
distinct credentials could reuse the wrong cached token; and because the
key omitted expire_early_secs and max_ttl, identical credentials under
different TTL policies shared one entry and the second policy was
bypassed. Key on a 128-bit digest of the resolved credentials plus those
TTL fields. The raw JSON still never enters the key or the log.
@AlinsRan

AlinsRan commented Jul 21, 2026

Copy link
Copy Markdown
Contributor Author

Fixed here + upstream #13699. Key is now md5(sa \0 expire_early_secs \0 max_ttl) .. "#" .. name: 128-bit so distinct creds cannot collide, TTL fields in the key so different policies do not alias, raw JSON stays out of the key/log.

Regression test (vertex-ai TEST 13): same name+creds with different max_ttl must not share a token — old key gave calls=1 distinct=false, fixed gives calls=2 distinct=true.

md5 follows the existing cache-key convention here; can move to SHA-256 if you prefer.

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.

3 participants