feat(zscaler_ai_guard): send the request user to AI Guard (#46094)

* Add user field to AI Guard execute-policy payload for dashboard attribution

Send the top-level OpenAI request `user` field in the execute-policy
JSON payload so AI Guard can surface usernames on its dashboard. This is
independent of the existing user_api_key_* headers which remain unchanged.

The upstream guardrail framework already passes the original request_data
(containing the `user` field) through to response-path guardrail calls,
so this works for both IN and OUT directions including streaming.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Apply suggestion from @greptile-apps[bot]

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* Add type annotations and tests for user field in execute-policy payload

- Add Final annotation to user_for_payload
- Add str | None type to user parameter in make_zscaler_ai_guard_api_call
- Add 3 tests: user included when present, omitted when absent,
  and user in/out of JSON body sent to AI Guard

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test(zscaler_ai_guard): inject the http client in user field tests

Pass an AsyncHTTPHandler into ZscalerAIGuard so the user field tests read the
real execute-policy body off a MockTransport instead of patching class methods,
and drop the comments that restated the lookup

* fix(zscaler_ai_guard): forward only non-empty string users

Read the request user through a typed helper and type the execute-policy call
params so basedpyright stays under the merge-base count, and skip non-string
values the vendor rejects with a 500

* test(zscaler_ai_guard): drop the test-only http_client

Run the user field tests against an in-process aiohttp server through api_base
so the guardrail keeps its original constructor

---------

Co-authored-by: Syamala Keerthana Chegu <schegu@zscaler.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: yucheng-berri <293926326+yucheng-berri@users.noreply.github.com>
This commit is contained in:
yucheng-berri 2026-10-12 16:13:22 -07:00 • committed by GitHub
parent ebc8e56aec
commit 02b5a395d8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 67 additions and 1 deletions

View file

@ -4,6 +4,7 @@
#
# +-------------------------------------------------------------+
import os
from collections.abc import Mapping
from typing import TYPE_CHECKING, Final, Literal, Optional
from fastapi import HTTPException
@ -105,6 +106,11 @@ class ZscalerAIGuard(CustomGuardrail):
super().update_in_memory_litellm_params(litellm_params)
self.timeout = self._resolve_timeout(litellm_params.timeout)
@staticmethod
def _resolve_request_user(request_data: Mapping[str, object]) -> str | None:
request_user: Final = request_data.get("user")
return request_user if isinstance(request_user, str) and request_user else None
@staticmethod
def _resolve_metadata_value(request_data: dict | None, key: str) -> str | None:
"""
@ -207,6 +213,8 @@ class ZscalerAIGuard(CustomGuardrail):
)
verbose_proxy_logger.debug("inside apply_guardrail kwargs: %s", kwargs)
user_for_payload: Final = self._resolve_request_user(request_data)
zscaler_ai_guard_result = None
direction: Final = "OUT" if input_type == "response" else "IN"
verbose_proxy_logger.debug("direction: %s", direction)
@ -219,6 +227,7 @@ class ZscalerAIGuard(CustomGuardrail):
policy_id=policy_id,
direction=direction,
content=concatenated_text,
user=user_for_payload,
**kwargs,
)
verbose_proxy_logger.debug("response from zscaler ai guards: %s", zscaler_ai_guard_result)
@ -357,7 +366,14 @@ class ZscalerAIGuard(CustomGuardrail):
raise HTTPException(status_code=response.status_code, detail=user_facing_error)
async def make_zscaler_ai_guard_api_call(
self, zscaler_ai_guard_url, api_key, policy_id, direction, content, **kwargs
self,
zscaler_ai_guard_url: str,
api_key: str | None,
policy_id,
direction: str,
content: str,
user: str | None = None,
**kwargs,
):
"""
Makes an API call to the Zscaler AI Guard service and handles retries, errors, and response parsing.
@ -374,6 +390,8 @@ class ZscalerAIGuard(CustomGuardrail):
# the policy from headers (e.g., user-api-key-alias)
if policy_id is not None and policy_id >= 1:
data["policyId"] = policy_id
if user:
data["user"] = user
try:
response: Final = await self._send_request(zscaler_ai_guard_url, extra_headers, data)
return self._handle_response(response, direction)

View file

@ -10,11 +10,15 @@ Tests covering:
- resolve-and-execute-policy endpoint (policyId omission)
"""
import asyncio
import importlib
import os
from typing import Final, Literal
from unittest.mock import AsyncMock, Mock, patch
import pytest
from aiohttp import web
from aiohttp.test_utils import TestServer
from fastapi import HTTPException
import litellm
@ -561,3 +565,47 @@ def setup_and_teardown():
if hasattr(litellm, "in_memory_llm_clients_cache"):
litellm.in_memory_llm_clients_cache.flush_cache()
yield
@pytest.mark.asyncio
@pytest.mark.parametrize("input_type", ["request", "response"])
@pytest.mark.parametrize(
("request_data", "expected_body_user"),
[
({"user": "alice@example.com"}, "alice@example.com"),
({}, None),
({"user": None}, None),
({"user": ""}, None),
({"user": 12345}, None),
({"user": ["alice@example.com"]}, None),
],
)
async def test_apply_guardrail_sends_request_user_in_execute_policy_body(
request_data: dict[str, object],
expected_body_user: str | None,
input_type: Literal["request", "response"],
) -> None:
received: Final = asyncio.Queue[object]()
async def execute_policy(request: web.Request) -> web.Response:
await received.put(await request.json())
return web.json_response({"statusCode": 200, "action": "ALLOW"})
app: Final = web.Application()
app.router.add_post("/v1/detection/execute-policy", execute_policy)
async with TestServer(app) as server:
guardrail: Final = ZscalerAIGuard(
api_key="test_key",
api_base=str(server.make_url("/v1/detection/execute-policy")),
policy_id=1,
)
await guardrail.apply_guardrail({"texts": ["hello"]}, request_data, input_type)
body: Final = received.get_nowait()
assert received.empty()
assert body == {
"direction": "OUT" if input_type == "response" else "IN",
"content": "hello",
"policyId": 1,
**({"user": expected_body_user} if expected_body_user is not None else {}),
}