Skip to content

Commit 639a9c3

Browse files
committed
🚧 initial implementation of forwarding x headers
1 parent 6a3cfd1 commit 639a9c3

7 files changed

Lines changed: 208 additions & 12 deletions

File tree

nemoguardrails/actions/llm/utils.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@
2525
from nemoguardrails.colang.v2_x.lang.colang_ast import Flow
2626
from nemoguardrails.colang.v2_x.runtime.flows import InternalEvent, InternalEvents
2727
from nemoguardrails.context import (
28+
api_request_headers_var,
29+
get_llm_needs_runtime_auth,
2830
llm_call_info_var,
2931
llm_response_metadata_var,
3032
reasoning_trace_var,
@@ -54,6 +56,45 @@
5456
]
5557

5658

59+
_INFRA_PREFIXES = ("x-forwarded", "x-real-", "x-request-id", "x-remote-")
60+
61+
62+
def get_extra_headers_from_request(forward_auth: bool = True) -> Optional[Dict[str, str]]:
63+
"""Forward X-* headers from the incoming request to the LLM call.
64+
65+
Excludes proxy/infra headers and the inbound Authorization header (which
66+
typically carries K8s/proxy auth and must never be forwarded to the LLM).
67+
When forward_auth is True, forwards X-Authorization as Authorization to
68+
the LLM provider.
69+
"""
70+
request_headers = api_request_headers_var.get()
71+
if not request_headers:
72+
return None
73+
74+
extra_headers = {}
75+
76+
for k, v in request_headers.items():
77+
lower = k.lower()
78+
if lower in ("authorization", "x-authorization"):
79+
continue
80+
if lower.startswith("x-") and not lower.startswith(_INFRA_PREFIXES):
81+
extra_headers[k] = v
82+
83+
if forward_auth:
84+
auth = request_headers.get("x-authorization")
85+
if auth:
86+
extra_headers["Authorization"] = auth
87+
88+
return extra_headers or None
89+
90+
91+
def get_extra_headers_for_llm(llm: "BaseLanguageModel") -> Dict[str, str]:
92+
"""Return extra_headers dict for an LLM based on its runtime auth needs."""
93+
needs_runtime_auth = get_llm_needs_runtime_auth(llm)
94+
extra_headers = get_extra_headers_from_request(forward_auth=needs_runtime_auth)
95+
return extra_headers or {}
96+
97+
5798
def _infer_provider_from_module(llm: BaseLanguageModel) -> Optional[str]:
5899
"""Infer provider name from the LLM's module path.
59100
@@ -224,6 +265,15 @@ async def llm_call(
224265
llm_params_with_stop = llm_params
225266

226267
filtered_params = _filter_params_for_openai_reasoning_models(llm, llm_params_with_stop)
268+
269+
# Forward X-* headers from the incoming request. Only forward Authorization
270+
# to models that need runtime auth (i.e. had no valid static API key at init).
271+
needs_runtime_auth = get_llm_needs_runtime_auth(llm)
272+
extra_headers = get_extra_headers_from_request(forward_auth=needs_runtime_auth)
273+
if extra_headers:
274+
filtered_params = filtered_params.copy() if filtered_params else {}
275+
filtered_params["extra_headers"] = extra_headers
276+
227277
generation_llm: Union[BaseLanguageModel, Runnable] = llm.bind(**filtered_params) if filtered_params else llm
228278

229279
if streaming_handler:

nemoguardrails/context.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,3 +62,24 @@
6262
llm_response_metadata_var: contextvars.ContextVar[Optional[dict]] = contextvars.ContextVar(
6363
"llm_response_metadata", default=None
6464
)
65+
66+
# The request headers from the incoming API request (set by the server layer).
67+
# Used to forward Authorization and X-* headers to outgoing LLM calls.
68+
api_request_headers_var: contextvars.ContextVar[Optional[Dict[str, str]]] = contextvars.ContextVar(
69+
"api_request_headers", default=None
70+
)
71+
72+
# Registry tracking which LLM instances need runtime auth (i.e. had no valid
73+
# static API key at init time). Keyed by id(llm) — safe because LLM objects
74+
# are server-lifetime singletons created once in _init_llms().
75+
_llm_needs_runtime_auth: Dict[int, bool] = {}
76+
77+
78+
def set_llm_needs_runtime_auth(llm: Any, needs_auth: bool) -> None:
79+
"""Register whether an LLM instance needs runtime auth from request headers."""
80+
_llm_needs_runtime_auth[id(llm)] = needs_auth
81+
82+
83+
def get_llm_needs_runtime_auth(llm: Any) -> bool:
84+
"""Check whether an LLM instance needs runtime auth from request headers."""
85+
return _llm_needs_runtime_auth.get(id(llm), False)

nemoguardrails/library/hallucination/actions.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from nemoguardrails import RailsConfig
2323
from nemoguardrails.actions import action
2424
from nemoguardrails.actions.llm.utils import (
25+
get_extra_headers_for_llm,
2526
get_multiline_response,
2627
llm_call,
2728
strip_quotes,
@@ -79,7 +80,11 @@ async def self_check_hallucination(
7980

8081
# Generate multiple responses with temperature 1.
8182
# Bind the config parameters to the LLM for this call
82-
llm_with_config = llm.bind(temperature=1.0, n=num_responses)
83+
bind_kwargs = {"temperature": 1.0, "n": num_responses}
84+
extra_headers = get_extra_headers_for_llm(llm)
85+
if extra_headers:
86+
bind_kwargs["extra_headers"] = extra_headers
87+
llm_with_config = llm.bind(**bind_kwargs)
8388
extra_llm_response = await llm_with_config.agenerate(
8489
[formatted_prompt],
8590
callbacks=logging_callback_manager_for_chain.handlers,

nemoguardrails/logging/callbacks.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@
1212
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
1313
# See the License for the specific language governing permissions and
1414
# limitations under the License.
15+
import copy
1516
import logging
17+
import re
1618
import uuid
1719
from time import time
1820
from typing import Any, Dict, List, Optional, cast
@@ -36,6 +38,20 @@
3638

3739
log = logging.getLogger(__name__)
3840

41+
_BEARER_RE = re.compile(r"(Bearer\s+)\S+", re.IGNORECASE)
42+
43+
44+
def _redact_invocation_params(params: Dict[str, Any]) -> Dict[str, Any]:
45+
"""Return a copy of invocation params with auth tokens redacted."""
46+
if "extra_headers" not in params:
47+
return params
48+
params = copy.copy(params)
49+
headers = params["extra_headers"]
50+
params["extra_headers"] = {
51+
k: _BEARER_RE.sub(r"\1[REDACTED]", v) if "authorization" in k.lower() else v for k, v in headers.items()
52+
}
53+
return params
54+
3955

4056
class LoggingCallbackHandler(AsyncCallbackHandler):
4157
"""Async callback handler that can be used to handle callbacks from langchain."""
@@ -64,7 +80,7 @@ async def on_llm_start(
6480
if explain_info:
6581
explain_info.llm_calls.append(llm_call_info)
6682

67-
log.info("Invocation Params :: %s", kwargs.get("invocation_params", {}))
83+
log.info("Invocation Params :: %s", _redact_invocation_params(kwargs.get("invocation_params", {})))
6884
log.info(
6985
"Prompt :: %s",
7086
prompts[0],
@@ -123,7 +139,7 @@ async def on_chat_model_start(
123139
]
124140
)
125141

126-
log.info("Invocation Params :: %s", kwargs.get("invocation_params", {}))
142+
log.info("Invocation Params :: %s", _redact_invocation_params(kwargs.get("invocation_params", {})))
127143
log.info(
128144
"Prompt Messages :: %s",
129145
prompt,

nemoguardrails/rails/llm/llmrails.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@
6666
generation_options_var,
6767
llm_stats_var,
6868
raw_llm_request,
69+
set_llm_needs_runtime_auth,
6970
streaming_handler_var,
7071
)
7172
from nemoguardrails.embeddings.index import EmbeddingsIndex
@@ -369,19 +370,31 @@ def _prepare_model_kwargs(self, model_config):
369370
model_config: The model configuration object
370371
371372
Returns:
372-
dict: The prepared kwargs for model initialization
373+
dict: kwargs dict for model initialization
373374
"""
374375
# Make a copy to avoid modifying the original model config
375376
kwargs = dict(model_config.parameters) if model_config.parameters else {}
376377

377-
# If the optional API Key Environment Variable is set, add it to kwargs
378378
if model_config.api_key_env_var:
379379
api_key = os.environ.get(model_config.api_key_env_var)
380380
if api_key:
381381
kwargs["api_key"] = api_key
382+
kwargs["openai_api_key"] = api_key
383+
elif "api_key" not in kwargs:
384+
# Placeholder to satisfy LangChain constructors. Real auth arrives
385+
# via forwarded request headers at call time. If no auth header is
386+
# provided, the LLM call will fail with a 401 — this is the intended
387+
# fail-safe: requests without valid auth are rejected by the provider.
388+
kwargs["api_key"] = "runtime-provided"
389+
kwargs["openai_api_key"] = "runtime-provided"
382390

383391
return kwargs
384392

393+
@staticmethod
394+
def _needs_runtime_auth(kwargs):
395+
"""Check if the model needs auth provided at request time."""
396+
return kwargs.get("api_key") == "runtime-provided"
397+
385398
def _init_llms(self):
386399
"""
387400
Initializes the right LLM engines based on the configuration.
@@ -421,6 +434,7 @@ def _init_llms(self):
421434
mode="chat",
422435
kwargs=kwargs,
423436
)
437+
set_llm_needs_runtime_auth(self.llm, self._needs_runtime_auth(kwargs))
424438
self.runtime.register_action_param("llm", self.llm)
425439

426440
else:
@@ -453,6 +467,7 @@ def _init_llms(self):
453467
mode=mode,
454468
kwargs=kwargs,
455469
)
470+
set_llm_needs_runtime_auth(llm_model, self._needs_runtime_auth(kwargs))
456471

457472
# Configure the model based on its type
458473
if llm_config.type == "main":

nemoguardrails/server/api.py

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@
1414
# limitations under the License.
1515

1616
import asyncio
17-
import contextvars
1817
import copy
1918
import importlib.util
2019
import json
@@ -37,6 +36,7 @@
3736
from starlette.staticfiles import StaticFiles
3837

3938
from nemoguardrails import LLMRails, RailsConfig, utils
39+
from nemoguardrails.context import api_request_headers_var
4040
from nemoguardrails.rails.llm.config import Model
4141
from nemoguardrails.rails.llm.options import (
4242
ActivatedRail,
@@ -91,9 +91,6 @@ def __init__(self, *args, **kwargs):
9191

9292
api_description = """Guardrails Sever API."""
9393

94-
# The headers for each request
95-
api_request_headers: contextvars.ContextVar = contextvars.ContextVar("headers")
96-
9794
# The datastore that the Server should use.
9895
# This is currently used only for storing threads.
9996
# TODO: refactor to wrap the FastAPI instance inside a RailsServer class
@@ -306,9 +303,13 @@ def _update_models_in_config(config: RailsConfig, main_model: Model) -> RailsCon
306303
break
307304

308305
if main_model_index is not None:
309-
parameters = {**models[main_model_index].parameters, **main_model.parameters}
306+
existing = models[main_model_index]
307+
parameters = {**existing.parameters, **main_model.parameters}
308+
# Preserve api_key_env_var from the original config if the override doesn't set one
309+
api_key_env_var = main_model.api_key_env_var or existing.api_key_env_var
310310
models[main_model_index] = main_model
311311
models[main_model_index].parameters = parameters
312+
models[main_model_index].api_key_env_var = api_key_env_var
312313
else:
313314
models.append(main_model)
314315

@@ -479,7 +480,7 @@ async def chat_completion(body: GuardrailsChatCompletionRequest, request: Reques
479480
asyncio.get_event_loop().create_task(logger({"endpoint": "/v1/chat/completions", "body": body.json()}))
480481

481482
# Save the request headers in a context variable.
482-
api_request_headers.set(request.headers)
483+
api_request_headers_var.set(dict(request.headers))
483484

484485
# Use Request config_ids if set, otherwise use the FastAPI default config.
485486
# If neither is available we can't generate any completions as we have no config_id
@@ -1103,7 +1104,7 @@ async def guardrail_checks(body: GuardrailsChatCompletionRequest, request: Reque
11031104
)
11041105
)
11051106

1106-
api_request_headers.set(request.headers)
1107+
api_request_headers_var.set(dict(request.headers))
11071108

11081109
async def process_checks():
11091110
"""Process guardrail checks and yield results.

tests/test_header_forwarding.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
16+
"""Tests for header forwarding and log redaction."""
17+
18+
from nemoguardrails.actions.llm.utils import get_extra_headers_from_request
19+
from nemoguardrails.context import api_request_headers_var
20+
from nemoguardrails.logging.callbacks import _redact_invocation_params
21+
22+
23+
def _set_headers(headers):
24+
api_request_headers_var.set(headers)
25+
26+
27+
def test_no_headers_returns_none():
28+
api_request_headers_var.set(None)
29+
assert get_extra_headers_from_request() is None
30+
31+
32+
def test_mixed_headers_full_scenario():
33+
"""Core test: infra filtered, x-auth wins, non-x ignored, custom forwarded."""
34+
_set_headers(
35+
{
36+
"authorization": "Bearer oauth-token",
37+
"x-authorization": "Bearer llm-key",
38+
"x-forwarded-for": "1.2.3.4",
39+
"x-remote-user": "admin",
40+
"x-real-ip": "5.6.7.8",
41+
"x-request-id": "abc",
42+
"x-maas-subscription": "sub-key",
43+
"content-type": "application/json",
44+
}
45+
)
46+
result = get_extra_headers_from_request(forward_auth=True)
47+
assert result == {
48+
"Authorization": "Bearer llm-key",
49+
"x-maas-subscription": "sub-key",
50+
}
51+
52+
53+
def test_forward_auth_false_skips_auth():
54+
_set_headers({"authorization": "Bearer key", "x-authorization": "Bearer key2"})
55+
assert get_extra_headers_from_request(forward_auth=False) is None
56+
57+
58+
def test_authorization_never_forwarded_to_llm():
59+
"""Authorization header (K8s/proxy auth) must never reach the LLM."""
60+
_set_headers({"authorization": "Bearer k8s-token"})
61+
result = get_extra_headers_from_request(forward_auth=True)
62+
assert result is None
63+
64+
65+
def test_x_authorization_forwarded_without_authorization():
66+
_set_headers({"x-authorization": "Bearer llm-key"})
67+
result = get_extra_headers_from_request(forward_auth=True)
68+
assert result == {"Authorization": "Bearer llm-key"}
69+
70+
71+
def test_redact_hides_bearer_tokens():
72+
params = {
73+
"model": "gpt-4",
74+
"extra_headers": {
75+
"Authorization": "Bearer sk-secret-123",
76+
"x-custom": "visible",
77+
},
78+
}
79+
result = _redact_invocation_params(params)
80+
assert result["extra_headers"]["Authorization"] == "Bearer [REDACTED]"
81+
assert result["extra_headers"]["x-custom"] == "visible"
82+
# original unchanged
83+
assert params["extra_headers"]["Authorization"] == "Bearer sk-secret-123"
84+
85+
86+
def test_redact_no_extra_headers_passthrough():
87+
params = {"model": "gpt-4"}
88+
assert _redact_invocation_params(params) is params

0 commit comments

Comments
 (0)