Skip to content

Commit b80cda7

Browse files
authored
feat: passthrough safety provider for forwarding to downstream /v1/moderations (#5004)
# What does this PR do? Right now, external providers can't access per-request headers like API tokens because NeedsRequestProviderData lives in llama_stack.core, not in the public llama-stack-api package. This adds a remote::passthrough safety provider that demonstrates the forward_headers pattern — a deployer-controlled mapping from X-LlamaStack-Provider-Data keys to outbound HTTP headers, so only the keys each provider needs get forwarded to the downstream service. The provider forwards moderation calls to any downstream HTTP service implementing OpenAI's /v1/moderations endpoint, supporting both run_shield (Llama Stack native) and run_moderation (OpenAI-compatible) paths. Hop-by-hop and framing headers (Host, Transfer-Encoding, etc.) are blocked via a `_BLOCKED_HEADERS` frozenset to prevent SSRF and request smuggling, and dunder keys like `__authenticated_user` never leak downstream. ### Usage ```yaml providers: - provider_id: passthrough provider_type: remote::passthrough config: base_url: ${env.SAFETY_SERVICE_URL} api_key: ${env.SAFETY_API_KEY:=} forward_headers: maas_api_token: Authorization tenant_id: X-Tenant-Id team_id: X-Team-Id ``` Clients pass per-request credentials via X-LlamaStack-Provider-Data, and only the keys listed in forward_headers get mapped to outbound HTTP headers on the downstream call. Relates #4607 Will open follow up PRs for other passthrough providers ## Test Plan ```bash uv run pytest tests/unit/providers/safety/ tests/integration/safety/test_passthrough.py -v ``` Manually tested against a local mock server. Full example: ```bash curl -s http://localhost:8321/v1/moderations \ -H "Content-Type: application/json" \ -H 'X-LlamaStack-Provider-Data: {"maas_api_token": "Bearer trustai-prod-xyz", "tenant_id": "enterprise-42", "host_override": "evil.internal"}' \ -d '{"input": "test", "model": "text-moderation-mock"}' # headers received by mock server: curl -s http://127.0.0.1:9999/v1/debug/last-headers | python3 -m json.tool # { # "Authorization": "Bearer trustai-prod-xyz", <-- forwarded from maas_api_token # "X-Tenant-Id": "enterprise-42", <-- forwarded from tenant_id # "Content-Type": "application/json", # "Host": "127.0.0.1:9999" <-- NOT evil.internal (blocked) # } ``` ## Breaking changes None
1 parent 1159906 commit b80cda7

13 files changed

Lines changed: 1439 additions & 0 deletions

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
description: "Passthrough safety provider that forwards moderation calls to a downstream HTTP service."
3+
sidebar_label: Remote - Passthrough
4+
title: remote::passthrough
5+
---
6+
7+
# remote::passthrough
8+
9+
## Description
10+
11+
Passthrough safety provider that forwards moderation calls to a downstream HTTP service.
12+
13+
## Configuration
14+
15+
| Field | Type | Required | Default | Description |
16+
|-------|------|----------|---------|-------------|
17+
| `base_url` | `HttpUrl` | No | | Base URL of the downstream safety service (e.g. https://safety.example.com/v1) |
18+
| `api_key` | `SecretStr \| None` | No | | API key for the downstream safety service. If set, takes precedence over provider data. |
19+
| `forward_headers` | `dict[str, str]` | No | &#123;&#125; | Mapping of provider data keys to outbound HTTP header names. Only keys listed here are forwarded from X-LlamaStack-Provider-Data to the downstream service. Example: &#123;"maas_api_token": "Authorization"&#125; |
20+
21+
## Sample Configuration
22+
23+
```yaml
24+
base_url: ${env.PASSTHROUGH_SAFETY_URL}
25+
api_key: ${env.PASSTHROUGH_SAFETY_API_KEY:=}
26+
```

src/llama_stack/providers/registry/safety.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,16 @@ def available_providers() -> list[ProviderSpec]:
6565
config_class="llama_stack.providers.remote.safety.nvidia.NVIDIASafetyConfig",
6666
description="NVIDIA's safety provider for content moderation and safety filtering.",
6767
),
68+
RemoteProviderSpec(
69+
api=Api.safety,
70+
adapter_type="passthrough",
71+
provider_type="remote::passthrough",
72+
pip_packages=[],
73+
module="llama_stack.providers.remote.safety.passthrough",
74+
config_class="llama_stack.providers.remote.safety.passthrough.PassthroughSafetyConfig",
75+
provider_data_validator="llama_stack.providers.remote.safety.passthrough.config.PassthroughProviderDataValidator",
76+
description="Passthrough safety provider that forwards moderation calls to a downstream HTTP service.",
77+
),
6878
RemoteProviderSpec(
6979
api=Api.safety,
7080
adapter_type="sambanova",
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# Copyright (c) Meta Platforms, Inc. and affiliates.
2+
# All rights reserved.
3+
#
4+
# This source code is licensed under the terms described in the LICENSE file in
5+
# the root directory of this source tree.
6+
7+
from typing import Any
8+
9+
from .config import PassthroughSafetyConfig
10+
11+
12+
async def get_adapter_impl(config: PassthroughSafetyConfig, _deps: Any) -> Any:
13+
from .passthrough import PassthroughSafetyAdapter
14+
15+
impl = PassthroughSafetyAdapter(config)
16+
await impl.initialize()
17+
return impl
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
# Copyright (c) Meta Platforms, Inc. and affiliates.
2+
# All rights reserved.
3+
#
4+
# This source code is licensed under the terms described in the LICENSE file in
5+
# the root directory of this source tree.
6+
7+
from typing import Any
8+
9+
from pydantic import BaseModel, ConfigDict, Field, HttpUrl, SecretStr, field_validator
10+
11+
from llama_stack_api import json_schema_type
12+
13+
_BLOCKED_HEADERS = frozenset(
14+
{
15+
"host",
16+
"content-type",
17+
"content-length",
18+
"transfer-encoding",
19+
"connection",
20+
"upgrade",
21+
"te",
22+
"trailer",
23+
"cookie",
24+
"set-cookie",
25+
}
26+
)
27+
28+
29+
class PassthroughProviderDataValidator(BaseModel):
30+
# allow arbitrary keys so forward_headers can access them
31+
model_config = ConfigDict(extra="allow")
32+
33+
passthrough_api_key: SecretStr | None = Field(
34+
default=None,
35+
description="API key for the downstream safety service",
36+
)
37+
38+
39+
@json_schema_type
40+
class PassthroughSafetyConfig(BaseModel):
41+
model_config = ConfigDict(extra="forbid")
42+
base_url: HttpUrl = Field(
43+
description="Base URL of the downstream safety service (e.g. https://safety.example.com/v1)",
44+
)
45+
api_key: SecretStr | None = Field(
46+
default=None,
47+
description="API key for the downstream safety service. If set, takes precedence over provider data.",
48+
)
49+
forward_headers: dict[str, str] = Field(
50+
default_factory=dict,
51+
description=(
52+
"Mapping of provider data keys to outbound HTTP header names. "
53+
"Only keys listed here are forwarded from X-LlamaStack-Provider-Data "
54+
'to the downstream service. Example: {"maas_api_token": "Authorization"}'
55+
),
56+
)
57+
58+
@field_validator("forward_headers")
59+
@classmethod
60+
def validate_forward_headers(cls, v: dict[str, str]) -> dict[str, str]:
61+
errors: list[str] = []
62+
for provider_key, header_name in v.items():
63+
if provider_key.startswith("__"):
64+
errors.append(f"provider key '{provider_key}' uses reserved __ prefix")
65+
if header_name.lower() in _BLOCKED_HEADERS:
66+
errors.append(f"header '{header_name}' is blocked (security-sensitive)")
67+
if errors:
68+
raise ValueError(f"invalid forward_headers: {'; '.join(errors)}")
69+
return v
70+
71+
@classmethod
72+
def sample_run_config(
73+
cls,
74+
base_url: str = "${env.PASSTHROUGH_SAFETY_URL}",
75+
api_key: str = "${env.PASSTHROUGH_SAFETY_API_KEY:=}",
76+
forward_headers: dict[str, str] | None = None,
77+
**kwargs: Any,
78+
) -> dict[str, Any]:
79+
config: dict[str, Any] = {
80+
"base_url": base_url,
81+
"api_key": api_key,
82+
}
83+
if forward_headers:
84+
config["forward_headers"] = forward_headers
85+
return config
Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
# Copyright (c) Meta Platforms, Inc. and affiliates.
2+
# All rights reserved.
3+
#
4+
# This source code is licensed under the terms described in the LICENSE file in
5+
# the root directory of this source tree.
6+
7+
import asyncio
8+
import uuid
9+
from typing import Any
10+
11+
import httpx
12+
from pydantic import SecretStr
13+
14+
from llama_stack.core.request_headers import NeedsRequestProviderData
15+
from llama_stack_api import (
16+
GetShieldRequest,
17+
ModerationObject,
18+
ModerationObjectResults,
19+
RunModerationRequest,
20+
RunShieldRequest,
21+
RunShieldResponse,
22+
Safety,
23+
SafetyViolation,
24+
Shield,
25+
ShieldsProtocolPrivate,
26+
ViolationLevel,
27+
)
28+
29+
from .config import PassthroughSafetyConfig
30+
31+
32+
class PassthroughSafetyAdapter(
33+
Safety,
34+
ShieldsProtocolPrivate,
35+
NeedsRequestProviderData,
36+
):
37+
"""Forwards safety calls to a downstream service via /v1/moderations."""
38+
39+
shield_store: Any # injected by framework after initialization
40+
41+
def __init__(self, config: PassthroughSafetyConfig) -> None:
42+
self.config = config
43+
self._client = httpx.AsyncClient(timeout=httpx.Timeout(30.0))
44+
45+
async def initialize(self) -> None:
46+
pass
47+
48+
async def shutdown(self) -> None:
49+
# shield so cancellation doesn't leak the connection
50+
await asyncio.shield(self._client.aclose())
51+
52+
async def register_shield(self, shield: Shield) -> None:
53+
pass
54+
55+
async def unregister_shield(self, identifier: str) -> None:
56+
pass
57+
58+
def _get_api_key(self) -> str | None:
59+
if self.config.api_key is not None:
60+
value = self.config.api_key.get_secret_value()
61+
if value:
62+
return value
63+
64+
provider_data = self.get_request_provider_data()
65+
if provider_data is not None and provider_data.passthrough_api_key:
66+
return str(provider_data.passthrough_api_key.get_secret_value())
67+
68+
return None
69+
70+
def _build_forward_headers(self) -> dict[str, str]:
71+
"""Build outbound headers from provider data using the forward_headers mapping."""
72+
if not self.config.forward_headers:
73+
return {}
74+
75+
provider_data = self.get_request_provider_data()
76+
if provider_data is None:
77+
return {}
78+
79+
headers: dict[str, str] = {}
80+
raw = provider_data.model_dump()
81+
for provider_key, header_name in self.config.forward_headers.items():
82+
value = raw.get(provider_key)
83+
if value is not None:
84+
# unwrap SecretStr so we forward the real value, not '**********'
85+
if isinstance(value, SecretStr):
86+
value = value.get_secret_value()
87+
# strip control chars that could enable header injection
88+
sanitized = str(value).replace("\r", "").replace("\n", "")
89+
headers[header_name] = sanitized
90+
return headers
91+
92+
def _build_request_headers(self) -> dict[str, str]:
93+
"""Combine auth + forwarded headers for the downstream request."""
94+
headers: dict[str, str] = {"Content-Type": "application/json"}
95+
96+
# forwarded headers go first so config api_key can't be overwritten
97+
headers.update(self._build_forward_headers())
98+
99+
api_key = self._get_api_key()
100+
if api_key:
101+
headers["Authorization"] = f"Bearer {api_key}"
102+
103+
return headers
104+
105+
async def run_shield(self, request: RunShieldRequest) -> RunShieldResponse:
106+
shield = await self.shield_store.get_shield(GetShieldRequest(identifier=request.shield_id))
107+
if not shield:
108+
raise ValueError(f"Shield {request.shield_id} not found")
109+
110+
# convert messages to a single string for the moderation payload
111+
texts: list[str] = []
112+
for msg in request.messages:
113+
content = msg.get("content", "") if isinstance(msg, dict) else getattr(msg, "content", "")
114+
if isinstance(content, str):
115+
texts.append(content)
116+
elif isinstance(content, list):
117+
# content parts - extract text parts
118+
for part in content:
119+
if isinstance(part, dict) and part.get("type") == "text":
120+
texts.append(part.get("text", ""))
121+
elif isinstance(part, str):
122+
texts.append(part)
123+
124+
if not texts:
125+
return RunShieldResponse(violation=None)
126+
127+
moderation_input = texts if len(texts) != 1 else texts[0]
128+
129+
payload = {
130+
"input": moderation_input,
131+
"model": shield.provider_resource_id or request.shield_id,
132+
}
133+
134+
base_url = str(self.config.base_url).rstrip("/")
135+
url = f"{base_url}/moderations"
136+
137+
headers = self._build_request_headers()
138+
139+
data = await self._post_moderation(url, payload, headers)
140+
return self._parse_moderation_response(data)
141+
142+
async def run_moderation(self, request: RunModerationRequest) -> ModerationObject:
143+
"""Forward directly to downstream /v1/moderations instead of going through run_shield."""
144+
inputs = request.input if isinstance(request.input, list) else [request.input]
145+
146+
payload: dict[str, str | list[str]] = {"input": request.input}
147+
if request.model is not None:
148+
payload["model"] = request.model
149+
150+
base_url = str(self.config.base_url).rstrip("/")
151+
url = f"{base_url}/moderations"
152+
153+
headers = self._build_request_headers()
154+
155+
data = await self._post_moderation(url, payload, headers)
156+
157+
# parse downstream response into our ModerationObject
158+
results_data = data.get("results")
159+
if not isinstance(results_data, list):
160+
raise RuntimeError("Downstream safety service returned malformed response (missing or invalid 'results')")
161+
results: list[ModerationObjectResults] = []
162+
163+
for result in results_data:
164+
if not isinstance(result, dict):
165+
raise RuntimeError("Downstream safety service returned malformed result entry (expected object)")
166+
flagged = result.get("flagged", False)
167+
categories = result.get("categories") or {}
168+
category_scores = result.get("category_scores") or {}
169+
170+
results.append(
171+
ModerationObjectResults(
172+
flagged=flagged,
173+
categories=categories,
174+
category_scores=category_scores,
175+
category_applied_input_types=result.get("category_applied_input_types"),
176+
user_message=None,
177+
metadata={},
178+
)
179+
)
180+
181+
if len(results) != len(inputs):
182+
raise RuntimeError(f"Downstream safety service returned {len(results)} results for {len(inputs)} inputs")
183+
184+
return ModerationObject(
185+
id=data.get("id", f"modr-{uuid.uuid4()}"),
186+
model=data.get("model", request.model or ""),
187+
results=results,
188+
)
189+
190+
async def _post_moderation(self, url: str, payload: dict[str, Any], headers: dict[str, str]) -> dict[str, Any]:
191+
try:
192+
response = await self._client.post(url, json=payload, headers=headers)
193+
response.raise_for_status()
194+
except httpx.TimeoutException as e:
195+
raise RuntimeError("Failed to reach downstream safety service: request timed out") from e
196+
except httpx.ConnectError as e:
197+
raise RuntimeError("Failed to reach downstream safety service: connection failed") from e
198+
except httpx.HTTPStatusError as e:
199+
if 400 <= e.response.status_code < 500:
200+
raise ValueError(
201+
f"Downstream safety service rejected the request (HTTP {e.response.status_code})"
202+
) from e
203+
raise RuntimeError(f"Downstream safety service returned HTTP {e.response.status_code}") from e
204+
except httpx.RequestError as e:
205+
raise RuntimeError("Failed to reach downstream safety service: unexpected request error") from e
206+
207+
try:
208+
raw = response.json()
209+
except (ValueError, UnicodeDecodeError) as e:
210+
raise RuntimeError(
211+
f"Downstream safety service returned non-JSON response (HTTP {response.status_code})"
212+
) from e
213+
214+
if not isinstance(raw, dict):
215+
raise RuntimeError("Downstream safety service returned invalid response (expected JSON object)")
216+
217+
return raw
218+
219+
def _parse_moderation_response(self, data: dict[str, Any]) -> RunShieldResponse:
220+
"""Convert a /v1/moderations JSON response into RunShieldResponse."""
221+
results = data.get("results")
222+
if not isinstance(results, list):
223+
raise RuntimeError("Downstream safety service returned malformed response (missing or invalid 'results')")
224+
if not results:
225+
raise RuntimeError("Downstream safety service returned empty results")
226+
227+
for result in results:
228+
if not isinstance(result, dict):
229+
raise RuntimeError("Downstream safety service returned malformed result entry (expected object)")
230+
if not result.get("flagged", False):
231+
continue
232+
233+
categories = result.get("categories") or {}
234+
flagged_categories = [cat for cat, flagged in categories.items() if flagged]
235+
violation_type = flagged_categories[0] if flagged_categories else "unsafe"
236+
237+
return RunShieldResponse(
238+
violation=SafetyViolation(
239+
violation_level=ViolationLevel.ERROR,
240+
user_message="Content was flagged by the safety service.",
241+
metadata={"violation_type": violation_type},
242+
)
243+
)
244+
245+
return RunShieldResponse(violation=None)

0 commit comments

Comments
 (0)