diff --git a/litellm/proxy/guardrails/guardrail_hooks/alice_wonderfence/credentials.py b/litellm/proxy/guardrails/guardrail_hooks/alice_wonderfence/credentials.py index fa9e48235f8..f282ae41197 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/alice_wonderfence/credentials.py +++ b/litellm/proxy/guardrails/guardrail_hooks/alice_wonderfence/credentials.py @@ -9,6 +9,11 @@ so a caller cannot bypass their assigned WonderFence app. ``allow_request_metadata_override`` defaults to False; enable only for trusted-gateway deployments that need request-level overrides. +The two metadata buckets (``metadata`` and ``litellm_metadata``) are merged +with the proxy-injected ``litellm_metadata`` winning on key collision, so admin +pins cannot be shadowed by a caller-supplied ``metadata`` body — see +``get_metadata``. + The stash bridges pre_call resolution into post_call where request metadata is gone — see ``stash_resolved`` for the full rationale. """ @@ -34,7 +39,19 @@ _LOGGING_OBJ_STASH_KEY = "alice_wonderfence_resolved" def get_metadata(request_data: dict) -> dict: - return request_data.get("metadata") or request_data.get("litellm_metadata") or {} + """Merge caller metadata with proxy-injected litellm_metadata. + + Proxy-injected values win on key collision so admin-pinned + user_api_key_metadata / user_api_key_team_metadata can never be shadowed + by a caller-supplied `metadata` body. On routes in LITELLM_METADATA_ROUTES + (e.g. /v1/responses) the admin pins live in `litellm_metadata` while the + caller bucket is `metadata`; on /chat/completions they coincide. + """ + caller = request_data.get("metadata") + litellm_md = request_data.get("litellm_metadata") + if isinstance(caller, dict) and isinstance(litellm_md, dict): + return {**caller, **litellm_md} + return caller or litellm_md or {} def resolve_api_key( diff --git a/tests/test_litellm/proxy/guardrails/guardrail_hooks/alice_wonderfence/test_credentials.py b/tests/test_litellm/proxy/guardrails/guardrail_hooks/alice_wonderfence/test_credentials.py index d06270fa83e..a5605bdbd14 100644 --- a/tests/test_litellm/proxy/guardrails/guardrail_hooks/alice_wonderfence/test_credentials.py +++ b/tests/test_litellm/proxy/guardrails/guardrail_hooks/alice_wonderfence/test_credentials.py @@ -207,13 +207,58 @@ def test_resolve_reads_litellm_metadata_when_metadata_absent(): ) -def test_get_metadata_prefers_metadata_over_litellm_metadata(): +def test_get_metadata_merges_with_litellm_metadata_winning(): + """When both buckets are present, merge them with proxy-injected + ``litellm_metadata`` winning on key collision; caller-only keys survive.""" data = { - "metadata": {"alice_wonderfence_app_id": "main"}, - "litellm_metadata": {"alice_wonderfence_app_id": "shadow"}, + "metadata": { + "alice_wonderfence_app_id": "caller-only", + "shared_key": "from-caller", + }, + "litellm_metadata": { + "shared_key": "from-litellm", + "user_api_key_metadata": {"alice_wonderfence_app_id": "admin-pinned"}, + }, + } + assert get_metadata(data) == { + "alice_wonderfence_app_id": "caller-only", + "shared_key": "from-litellm", + "user_api_key_metadata": {"alice_wonderfence_app_id": "admin-pinned"}, } - assert get_metadata(data) == {"alice_wonderfence_app_id": "main"} def test_get_metadata_returns_empty_when_both_absent(): assert get_metadata({}) == {} + + +def test_responses_route_admin_pin_beats_caller_metadata(): + """Mirror the /v1/responses shape: caller `metadata` carries a + request-override app_id while the admin pin lives in + `litellm_metadata.user_api_key_metadata`. The admin pin must win even with + the override flag enabled — the caller bucket must not shadow it.""" + data = { + "model": "gpt-4", + "metadata": {"alice_wonderfence_app_id": "caller-override"}, + "litellm_metadata": { + "user_api_key_metadata": {"alice_wonderfence_app_id": "admin-pinned"} + }, + } + assert resolve_app_id(data, allow_request_metadata_override=True) == "admin-pinned" + + +def test_responses_route_admin_pin_beats_caller_metadata_api_key(): + """api_key variant of the /v1/responses regression: admin-pinned key + metadata wins over a caller-supplied request-override api_key.""" + data = { + "model": "gpt-4", + "metadata": {"alice_wonderfence_api_key": "caller-override"}, + "litellm_metadata": { + "user_api_key_metadata": {"alice_wonderfence_api_key": "admin-pinned"} + }, + } + assert ( + resolve_api_key( + data, default_api_key="default", allow_request_metadata_override=True + ) + == "admin-pinned" + )