From e6eb6a4a4d4e8d6ee515bce42a02af29632421a1 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Mon, 24 Aug 2026 11:21:26 -0700 Subject: [PATCH 01/20] fix(passthrough): stop leaking the caller's virtual key on credential-less Vertex passthrough When no Vertex credential is configured (no default_vertex_config, no matching use_in_pass_through deployment, no vector-store credential), the Vertex passthrough took the bring-your-own-credentials branch and forwarded the entire incoming header set upstream to Google. That set included whichever header carried the caller's LiteLLM virtual key: x-litellm-api-key, or Authorization when get_litellm_virtual_key read the key from there. The proxy's own secret was sent to a third-party provider. The credential-less branch now drops x-litellm-api-key and the Authorization value that equals the virtual key, keeping a genuine bring-your-own Google credential (an OAuth token in Authorization, or x-goog-api-key) so real BYO passthrough still works. When neither survives, the request fails with a clean 401 telling the operator no credential is configured, instead of forwarding the virtual key. Regression coverage in the mapped test path asserts the 401-and-never-forwarded behavior for both leak vectors and that a real Google credential still passes through with the virtual key stripped. --- .../llm_passthrough_endpoints.py | 50 ++++- .../test_llm_pass_through_endpoints.py | 188 +++++++++++++++--- 2 files changed, 198 insertions(+), 40 deletions(-) diff --git a/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py b/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py index 7ce41c1d5b6..0650735686f 100644 --- a/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py +++ b/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py @@ -9,7 +9,7 @@ Use litellm with Anthropic SDK, Vertex AI SDK, Cohere SDK, etc. import json import os import re -from collections.abc import Callable +from collections.abc import Callable, Mapping from types import MappingProxyType from typing import TYPE_CHECKING, Annotated, Any, Final, cast @@ -1726,6 +1726,42 @@ def _override_vertex_params_from_router_credentials( return vertex_project, vertex_location +_CREDENTIALLESS_VERTEX_MISSING_CREDENTIAL_DETAIL: Final = ( + "No Vertex AI credential is configured on this proxy and the request carried no upstream " + "Google credential. The LiteLLM virtual key is not forwarded to Google. Configure a Vertex " + "credential (DEFAULT_VERTEXAI_PROJECT / DEFAULT_VERTEXAI_LOCATION / DEFAULT_VERTEXAI_CREDENTIALS, " + "or a model with use_in_pass_through: true), or send your own Google OAuth token in the " + "Authorization header." +) + + +def _forwarded_headers_for_credentialless_vertex_passthrough(request: Request) -> Mapping[str, str]: + """ + Header set to forward on the bring-your-own-credentials Vertex passthrough + branch, used when the proxy has no Vertex credential configured. + + The LiteLLM virtual key that authenticated the caller is never forwarded to + Google: whichever header carried it (``x-litellm-api-key``, or ``Authorization`` + when that is what ``get_litellm_virtual_key`` consumed) is dropped. A caller may + still bring their own Google credential in the ``Authorization`` (OAuth token) or + ``x-goog-api-key`` header; when neither is present the request is rejected so the + virtual key cannot leak upstream. + """ + incoming: Final = _safe_get_request_headers(request) + litellm_virtual_key: Final = get_litellm_virtual_key(request) + forwarded: Final = MappingProxyType( + { + name: value + for name, value in incoming.items() + if name not in ("content-length", "host", "x-litellm-api-key") + and not (name == "authorization" and value == litellm_virtual_key) + } + ) + if "authorization" not in forwarded and "x-goog-api-key" not in forwarded: + raise HTTPException(status_code=401, detail=_CREDENTIALLESS_VERTEX_MISSING_CREDENTIAL_DETAIL) + return forwarded + + async def _prepare_vertex_auth_headers( request: Request, vertex_credentials: Any | None, @@ -1734,7 +1770,7 @@ async def _prepare_vertex_auth_headers( vertex_location: str | None, base_target_url: str | None, get_vertex_pass_through_handler: BaseVertexAIPassThroughHandler, -) -> tuple[dict, str | None, bool, str | None, str | None]: +) -> tuple[Mapping[str, str], str | None, bool, str | None, str | None]: """ Prepare authentication headers for Vertex AI pass-through requests. @@ -1760,11 +1796,11 @@ async def _prepare_vertex_auth_headers( # Use headers from the incoming request if no vertex credentials are found if (vertex_credentials is None or vertex_credentials.vertex_project is None) and router_credentials is None: - headers = _safe_get_request_headers(request).copy() + headers = _forwarded_headers_for_credentialless_vertex_passthrough(request) headers_passed_through = True - verbose_proxy_logger.debug("default_vertex_config not set, incoming request headers %s", headers) - headers.pop("content-length", None) - headers.pop("host", None) + verbose_proxy_logger.debug( + "default_vertex_config not set, forwarding caller-provided headers %s", tuple(headers.keys()) + ) else: if router_credentials is not None: vertex_credentials_str = None @@ -1850,7 +1886,7 @@ async def _base_vertex_proxy_route( encoded_endpoint = httpx.URL(endpoint).path verbose_proxy_logger.debug("requested endpoint %s", endpoint) - headers: dict = {} + headers: Mapping[str, str] = {} api_key_to_use = get_litellm_virtual_key(request=request) user_api_key_dict = await user_api_key_auth( request=request, diff --git a/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py b/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py index ac140abe31f..9581068f9fc 100644 --- a/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py +++ b/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py @@ -553,10 +553,9 @@ class TestVertexAIPassThroughHandler: @pytest.mark.asyncio async def test_vertex_passthrough_with_no_default_credentials(self, monkeypatch): """ - Test that when no default credentials are set, the request fails - """ - """ - Test that when passthrough credentials are set, they are correctly used in the request + With no Vertex credential matching the request, the only Authorization present + is the caller's own virtual key. It must not be forwarded to Google; the + request fails with a clean 401 instead (LIT-5997). """ from litellm.proxy.pass_through_endpoints.passthrough_endpoint_router import ( PassthroughEndpointRouter, @@ -619,31 +618,25 @@ class TestVertexAIPassThroughHandler: mock_get_token.return_value = (test_token, "") mock_auth.return_value = MagicMock() - # Call the route - try: + with pytest.raises(HTTPException) as exc_info: await vertex_proxy_route( endpoint=endpoint, request=mock_request, fastapi_response=mock_response, ) - except Exception as e: - traceback.print_exc() - print(f"Error: {e}") - # Verify create_pass_through_route was called with correct arguments - mock_create_route.assert_called_once_with( - endpoint=endpoint, - target=f"https://{test_location}-aiplatform.googleapis.com/v1/projects/{test_project}/locations/{test_location}/publishers/google/models/gemini-1.5-flash:generateContent", - custom_headers={"authorization": f"Bearer {test_token}"}, - is_streaming_request=False, - ) + assert exc_info.value.status_code == 401 + mock_create_route.assert_not_called() @pytest.mark.asyncio async def test_async_vertex_proxy_route_api_key_auth(self): """ Critical - This is how Vertex AI JS SDK will Auth to Litellm Proxy + This is how Vertex AI JS SDK will Auth to Litellm Proxy: the virtual key + arrives in x-litellm-api-key and must reach user_api_key_auth. With no Vertex + credential configured, that virtual key must not be forwarded to Google, so + the request fails with a clean 401 (LIT-5997). """ # Mock dependencies mock_request = Mock() @@ -663,14 +656,15 @@ class TestVertexAIPassThroughHandler: return_value={"status": "success"} ) - # Call the function - result = await vertex_proxy_route( - endpoint="v1/projects/test-project/locations/us-central1/publishers/google/models/gemini-1.5-pro:generateContent", - request=mock_request, - fastapi_response=mock_response, - ) + with pytest.raises(HTTPException) as exc_info: + await vertex_proxy_route( + endpoint="v1/projects/test-project/locations/us-central1/publishers/google/models/gemini-1.5-pro:generateContent", + request=mock_request, + fastapi_response=mock_response, + ) - # Verify user_api_key_auth was called with the correct Bearer token + assert exc_info.value.status_code == 401 + mock_pass_through.assert_not_called() mock_auth.assert_called_once() call_args = mock_auth.call_args[1] assert call_args["api_key"] == "Bearer test-key-123" @@ -1338,7 +1332,9 @@ class TestVertexAIDiscoveryPassThroughHandler: @pytest.mark.asyncio async def test_vertex_discovery_proxy_route_api_key_auth(self): """ - Test that the route correctly handles API key authentication + The virtual key arrives in x-litellm-api-key and must reach user_api_key_auth. + With no Vertex credential configured, that virtual key must not be forwarded to + Google, so the request fails with a clean 401 (LIT-5997). """ # Mock dependencies mock_request = Mock() @@ -1358,14 +1354,15 @@ class TestVertexAIDiscoveryPassThroughHandler: return_value={"status": "success"} ) - # Call the function - result = await vertex_discovery_proxy_route( - endpoint="v1/projects/test-project/locations/us-central1/dataStores/default/servingConfigs/default:search", - request=mock_request, - fastapi_response=mock_response, - ) + with pytest.raises(HTTPException) as exc_info: + await vertex_discovery_proxy_route( + endpoint="v1/projects/test-project/locations/us-central1/dataStores/default/servingConfigs/default:search", + request=mock_request, + fastapi_response=mock_response, + ) - # Verify user_api_key_auth was called with the correct Bearer token + assert exc_info.value.status_code == 401 + mock_pass_through.assert_not_called() mock_auth.assert_called_once() call_args = mock_auth.call_args[1] assert call_args["api_key"] == "Bearer test-key-123" @@ -3312,7 +3309,10 @@ class TestVertexRawPredictStreamingClassification: "type": "http", "method": "POST", "path": f"/vertex_ai/{endpoint}", - "headers": [(b"content-type", b"application/json")], + "headers": [ + (b"content-type", b"application/json"), + (b"authorization", b"Bearer ya29.byo-google-oauth"), + ], "query_string": b"", }, receive=receive, @@ -3445,6 +3445,128 @@ def test_is_passthrough_request_streaming_tolerates_non_object_bodies(request_bo assert is_passthrough_request_streaming(request_body) is expected +class TestVertexCredentiallessPassthroughVirtualKeyLeak: + """Regression coverage for LIT-5997. + + With no Vertex credential configured, the passthrough took the + bring-your-own-credentials branch and forwarded the whole incoming header set + to Google, including whichever header carried the caller's LiteLLM virtual key + (``Authorization: Bearer `` or ``x-litellm-api-key: ``). That leaked + the proxy's own secret to an upstream provider. + + A credential-less request that carries no upstream Google credential must now + fail with a clean 401 and never reach ``create_pass_through_route``; a genuine + bring-your-own Google credential must still pass through, with the virtual key + stripped from what is forwarded. + """ + + VKEY = "sk-litellm-victim-key" + ENDPOINT = ( + "v1/projects/my-proj/locations/us-central1/publishers/google/models/" + "gemini-2.5-flash:generateContent" + ) + + async def _run( + self, monkeypatch, headers: list[tuple[bytes, bytes]] + ) -> tuple[HTTPException | None, dict | None]: + from litellm.proxy.pass_through_endpoints.passthrough_endpoint_router import ( + PassthroughEndpointRouter, + ) + + async def receive(): + return {"type": "http.request", "body": b"{}", "more_body": False} + + request = Request( + { + "type": "http", + "method": "POST", + "path": f"/vertex_ai/{self.ENDPOINT}", + "headers": headers, + "query_string": b"", + }, + receive=receive, + ) + + captured: dict = {} + + def fake_create_pass_through_route(**kwargs): + captured.update(kwargs) + return AsyncMock(return_value={"status": "success"}) + + mock_handler = Mock() + mock_handler.get_default_base_target_url.return_value = "https://us-central1-aiplatform.googleapis.com/" + + module = "litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints" + monkeypatch.setattr(f"{module}.passthrough_endpoint_router", PassthroughEndpointRouter()) + raised: HTTPException | None = None + with ( + mock.patch(f"{module}.create_pass_through_route", side_effect=fake_create_pass_through_route), + mock.patch(f"{module}.user_api_key_auth", new=AsyncMock(return_value=UserAPIKeyAuth(token="hashed"))), + mock.patch(f"{module}.get_vertex_pass_through_handler", return_value=mock_handler), + ): + try: + await vertex_proxy_route( + endpoint=self.ENDPOINT, + request=request, + fastapi_response=Response(), + user_api_key_dict=UserAPIKeyAuth(token="hashed"), + ) + except HTTPException as exc: + raised = exc + + return raised, (captured.get("custom_headers") if captured else None) + + @pytest.mark.asyncio + async def test_authorization_bearer_virtual_key_is_rejected_not_forwarded(self, monkeypatch): + raised, forwarded = await self._run( + monkeypatch, + [(b"authorization", f"Bearer {self.VKEY}".encode()), (b"content-type", b"application/json")], + ) + assert forwarded is None, "credential-less request must never reach the upstream forwarder" + assert raised is not None and raised.status_code == 401 + + @pytest.mark.asyncio + async def test_x_litellm_api_key_virtual_key_is_rejected_not_forwarded(self, monkeypatch): + raised, forwarded = await self._run( + monkeypatch, + [(b"x-litellm-api-key", self.VKEY.encode()), (b"content-type", b"application/json")], + ) + assert forwarded is None, "credential-less request must never reach the upstream forwarder" + assert raised is not None and raised.status_code == 401 + + @pytest.mark.asyncio + async def test_byo_google_oauth_token_still_forwards_without_virtual_key(self, monkeypatch): + raised, forwarded = await self._run( + monkeypatch, + [ + (b"x-litellm-api-key", self.VKEY.encode()), + (b"authorization", b"Bearer ya29.google-oauth-token"), + (b"content-type", b"application/json"), + ], + ) + assert raised is None + assert forwarded is not None + assert forwarded.get("authorization") == "Bearer ya29.google-oauth-token" + assert "x-litellm-api-key" not in forwarded + assert self.VKEY not in " ".join(f"{name}:{value}" for name, value in forwarded.items()) + + @pytest.mark.asyncio + async def test_byo_x_goog_api_key_still_forwards_without_virtual_key(self, monkeypatch): + raised, forwarded = await self._run( + monkeypatch, + [ + (b"x-litellm-api-key", self.VKEY.encode()), + (b"x-goog-api-key", b"AIza-google-api-key"), + (b"content-type", b"application/json"), + ], + ) + assert raised is None + assert forwarded is not None + assert forwarded.get("x-goog-api-key") == "AIza-google-api-key" + assert "x-litellm-api-key" not in forwarded + assert self.VKEY not in " ".join(f"{name}:{value}" for name, value in forwarded.items()) + + class TestGetAzureAISearchIndexFromEndpoint: """The operable index is only the segment right after ``indexes``. From 4bc097733faf642c8751c4c8c3a4b2d6aecd2fe8 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Mon, 24 Aug 2026 11:51:23 -0700 Subject: [PATCH 02/20] fix(passthrough): strip virtual key from all headers on credential-less Vertex forward The credential-less Vertex passthrough dropped the caller's LiteLLM virtual key only from Authorization by exact match. A caller who sent the same key in x-goog-api-key (which doubles as a real Google credential) had it accepted as a credential and forwarded upstream. Drop the virtual key by value across every forwarded header, normalizing any Bearer prefix, so no header name carries it to Google. --- .../llm_passthrough_endpoints.py | 23 +++++++++++++------ .../test_llm_pass_through_endpoints.py | 13 +++++++++++ 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py b/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py index 0650735686f..eaed5185fa8 100644 --- a/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py +++ b/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py @@ -1735,26 +1735,35 @@ _CREDENTIALLESS_VERTEX_MISSING_CREDENTIAL_DETAIL: Final = ( ) +def _bearer_stripped(value: str) -> str: + parts: Final = value.split(None, 1) + if len(parts) == 2 and parts[0].lower() == "bearer": + return parts[1] + return value + + def _forwarded_headers_for_credentialless_vertex_passthrough(request: Request) -> Mapping[str, str]: """ Header set to forward on the bring-your-own-credentials Vertex passthrough branch, used when the proxy has no Vertex credential configured. The LiteLLM virtual key that authenticated the caller is never forwarded to - Google: whichever header carried it (``x-litellm-api-key``, or ``Authorization`` - when that is what ``get_litellm_virtual_key`` consumed) is dropped. A caller may - still bring their own Google credential in the ``Authorization`` (OAuth token) or - ``x-goog-api-key`` header; when neither is present the request is rejected so the - virtual key cannot leak upstream. + Google. LiteLLM accepts that key from several headers (``Authorization``, + ``x-litellm-api-key``, ``x-goog-api-key``, ``api-key``, ``x-api-key``), and + ``x-goog-api-key`` doubles as a genuine Google credential, so the key is dropped + by value across every header rather than by name. A caller may still bring their + own Google credential in the ``Authorization`` (OAuth token) or ``x-goog-api-key`` + header; when neither survives the request is rejected so the virtual key cannot + leak upstream. """ incoming: Final = _safe_get_request_headers(request) - litellm_virtual_key: Final = get_litellm_virtual_key(request) + caller_virtual_key: Final = _bearer_stripped(get_litellm_virtual_key(request)) forwarded: Final = MappingProxyType( { name: value for name, value in incoming.items() if name not in ("content-length", "host", "x-litellm-api-key") - and not (name == "authorization" and value == litellm_virtual_key) + and not (caller_virtual_key and _bearer_stripped(value) == caller_virtual_key) } ) if "authorization" not in forwarded and "x-goog-api-key" not in forwarded: diff --git a/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py b/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py index 9581068f9fc..fe0d1a65c70 100644 --- a/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py +++ b/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py @@ -3534,6 +3534,19 @@ class TestVertexCredentiallessPassthroughVirtualKeyLeak: assert forwarded is None, "credential-less request must never reach the upstream forwarder" assert raised is not None and raised.status_code == 401 + @pytest.mark.asyncio + async def test_x_goog_api_key_carrying_virtual_key_is_rejected_not_forwarded(self, monkeypatch): + raised, forwarded = await self._run( + monkeypatch, + [ + (b"x-litellm-api-key", self.VKEY.encode()), + (b"x-goog-api-key", self.VKEY.encode()), + (b"content-type", b"application/json"), + ], + ) + assert forwarded is None, "the virtual key in x-goog-api-key must not satisfy the gate nor be forwarded" + assert raised is not None and raised.status_code == 401 + @pytest.mark.asyncio async def test_byo_google_oauth_token_still_forwards_without_virtual_key(self, monkeypatch): raised, forwarded = await self._run( From 088a700933cf77eb6c33796da0cd2d9732a345ec Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Mon, 24 Aug 2026 12:00:21 -0700 Subject: [PATCH 03/20] test(passthrough): cover virtual key echoed in api-key and x-api-key Adds a regression asserting the value-based strip also drops the caller's virtual key when it is duplicated into the api-key and x-api-key headers, while a genuine bring-your-own Google credential still forwards. --- .../test_llm_pass_through_endpoints.py | 30 ++++++++++++++++--- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py b/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py index fe0d1a65c70..52ff6e709c5 100644 --- a/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py +++ b/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py @@ -3450,14 +3450,16 @@ class TestVertexCredentiallessPassthroughVirtualKeyLeak: With no Vertex credential configured, the passthrough took the bring-your-own-credentials branch and forwarded the whole incoming header set - to Google, including whichever header carried the caller's LiteLLM virtual key - (``Authorization: Bearer `` or ``x-litellm-api-key: ``). That leaked - the proxy's own secret to an upstream provider. + to Google, including whichever header carried the caller's LiteLLM virtual key. + LiteLLM accepts that key from several headers (``Authorization``, + ``x-litellm-api-key``, ``x-goog-api-key``, ``api-key``, ``x-api-key``), and + ``x-goog-api-key`` doubles as a genuine Google credential, so any of them could + leak the proxy's own secret to an upstream provider. A credential-less request that carries no upstream Google credential must now fail with a clean 401 and never reach ``create_pass_through_route``; a genuine bring-your-own Google credential must still pass through, with the virtual key - stripped from what is forwarded. + stripped by value from every forwarded header. """ VKEY = "sk-litellm-victim-key" @@ -3579,6 +3581,26 @@ class TestVertexCredentiallessPassthroughVirtualKeyLeak: assert "x-litellm-api-key" not in forwarded assert self.VKEY not in " ".join(f"{name}:{value}" for name, value in forwarded.items()) + @pytest.mark.asyncio + async def test_virtual_key_echoed_in_alternate_auth_headers_is_stripped_by_value(self, monkeypatch): + raised, forwarded = await self._run( + monkeypatch, + [ + (b"x-litellm-api-key", self.VKEY.encode()), + (b"authorization", b"Bearer ya29.google-oauth-token"), + (b"api-key", self.VKEY.encode()), + (b"x-api-key", self.VKEY.encode()), + (b"content-type", b"application/json"), + ], + ) + assert raised is None + assert forwarded is not None + assert forwarded.get("authorization") == "Bearer ya29.google-oauth-token" + assert "api-key" not in forwarded + assert "x-api-key" not in forwarded + assert "x-litellm-api-key" not in forwarded + assert self.VKEY not in " ".join(f"{name}:{value}" for name, value in forwarded.items()) + class TestGetAzureAISearchIndexFromEndpoint: """The operable index is only the segment right after ``indexes``. From e7c2ede159c9cf312282a574ba8b1f20a09c2693 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Mon, 24 Aug 2026 12:08:43 -0700 Subject: [PATCH 04/20] fix(vertex-passthrough): never forward proxy auth headers to Google On the credential-less Vertex passthrough branch, drop every header that can only carry LiteLLM caller auth (x-litellm-api-key, api-key, x-api-key) by name, since Google never consumes them, and strip the virtual key by value from Authorization / x-goog-api-key, which may instead hold a genuine bring-your-own Google credential. This closes the residual leak where a distinct caller secret in api-key or x-api-key still reached upstream. --- .../llm_passthrough_endpoints.py | 26 ++++++++++++------- .../test_llm_pass_through_endpoints.py | 19 +++++++++----- 2 files changed, 29 insertions(+), 16 deletions(-) diff --git a/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py b/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py index eaed5185fa8..75e3baf2c4a 100644 --- a/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py +++ b/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py @@ -1742,19 +1742,27 @@ def _bearer_stripped(value: str) -> str: return value +_HEADERS_NEVER_FORWARDED_TO_VERTEX: Final = frozenset( + {"content-length", "host", "x-litellm-api-key", "api-key", "x-api-key"} +) + + def _forwarded_headers_for_credentialless_vertex_passthrough(request: Request) -> Mapping[str, str]: """ Header set to forward on the bring-your-own-credentials Vertex passthrough branch, used when the proxy has no Vertex credential configured. - The LiteLLM virtual key that authenticated the caller is never forwarded to - Google. LiteLLM accepts that key from several headers (``Authorization``, - ``x-litellm-api-key``, ``x-goog-api-key``, ``api-key``, ``x-api-key``), and - ``x-goog-api-key`` doubles as a genuine Google credential, so the key is dropped - by value across every header rather than by name. A caller may still bring their - own Google credential in the ``Authorization`` (OAuth token) or ``x-goog-api-key`` - header; when neither survives the request is rejected so the virtual key cannot - leak upstream. + No credential the proxy accepts for caller authentication is forwarded to + Google. LiteLLM reads the caller's virtual key from ``x-litellm-api-key``, + ``api-key``, ``x-api-key``, ``Authorization``, and ``x-goog-api-key``. Vertex + only ever authenticates with an OAuth token in ``Authorization`` or an API key + in ``x-goog-api-key``, so ``x-litellm-api-key`` / ``api-key`` / ``x-api-key`` + can only carry caller auth material and are dropped by name. ``Authorization`` + and ``x-goog-api-key`` may instead carry a genuine bring-your-own Google + credential, so they are kept unless their value is the caller's virtual key, + which is dropped by value (normalizing any ``Bearer`` prefix). When neither a + surviving ``Authorization`` nor ``x-goog-api-key`` remains the request is + rejected so the virtual key cannot leak upstream. """ incoming: Final = _safe_get_request_headers(request) caller_virtual_key: Final = _bearer_stripped(get_litellm_virtual_key(request)) @@ -1762,7 +1770,7 @@ def _forwarded_headers_for_credentialless_vertex_passthrough(request: Request) - { name: value for name, value in incoming.items() - if name not in ("content-length", "host", "x-litellm-api-key") + if name not in _HEADERS_NEVER_FORWARDED_TO_VERTEX and not (caller_virtual_key and _bearer_stripped(value) == caller_virtual_key) } ) diff --git a/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py b/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py index 52ff6e709c5..e268c8cd2b9 100644 --- a/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py +++ b/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py @@ -3457,9 +3457,11 @@ class TestVertexCredentiallessPassthroughVirtualKeyLeak: leak the proxy's own secret to an upstream provider. A credential-less request that carries no upstream Google credential must now - fail with a clean 401 and never reach ``create_pass_through_route``; a genuine - bring-your-own Google credential must still pass through, with the virtual key - stripped by value from every forwarded header. + fail with a clean 401 and never reach ``create_pass_through_route``. The + proxy-only auth headers Google never consumes (``x-litellm-api-key``, + ``api-key``, ``x-api-key``) are dropped by name, and the virtual key is dropped + by value from ``Authorization`` / ``x-goog-api-key``, which may instead carry a + genuine bring-your-own Google credential that must still pass through. """ VKEY = "sk-litellm-victim-key" @@ -3582,14 +3584,14 @@ class TestVertexCredentiallessPassthroughVirtualKeyLeak: assert self.VKEY not in " ".join(f"{name}:{value}" for name, value in forwarded.items()) @pytest.mark.asyncio - async def test_virtual_key_echoed_in_alternate_auth_headers_is_stripped_by_value(self, monkeypatch): + async def test_alternate_proxy_auth_headers_are_never_forwarded_to_google(self, monkeypatch): raised, forwarded = await self._run( monkeypatch, [ (b"x-litellm-api-key", self.VKEY.encode()), (b"authorization", b"Bearer ya29.google-oauth-token"), - (b"api-key", self.VKEY.encode()), - (b"x-api-key", self.VKEY.encode()), + (b"api-key", b"azure-style-caller-secret"), + (b"x-api-key", b"anthropic-style-caller-secret"), (b"content-type", b"application/json"), ], ) @@ -3599,7 +3601,10 @@ class TestVertexCredentiallessPassthroughVirtualKeyLeak: assert "api-key" not in forwarded assert "x-api-key" not in forwarded assert "x-litellm-api-key" not in forwarded - assert self.VKEY not in " ".join(f"{name}:{value}" for name, value in forwarded.items()) + forwarded_blob = " ".join(f"{name}:{value}" for name, value in forwarded.items()) + assert self.VKEY not in forwarded_blob + assert "azure-style-caller-secret" not in forwarded_blob + assert "anthropic-style-caller-secret" not in forwarded_blob class TestGetAzureAISearchIndexFromEndpoint: From 26e47aea32636c2ffd98e2b34047a06c22fab0b6 Mon Sep 17 00:00:00 2001 From: Tin Chi Lo Date: Mon, 24 Aug 2026 15:28:20 -0400 Subject: [PATCH 05/20] fix(auto-router): list configured auto-routers in the usage picker before they have traffic --- .../auto_router_endpoints.py | 62 +++++++- .../auto_router_endpoints.py | 13 +- .../test_auto_router_endpoints.py | 147 ++++++++++++++++++ ui/litellm-dashboard/src/lib/http/schema.d.ts | 15 +- 4 files changed, 230 insertions(+), 7 deletions(-) diff --git a/litellm/proxy/management_endpoints/auto_router_endpoints.py b/litellm/proxy/management_endpoints/auto_router_endpoints.py index 46aac82473c..1322f50d4af 100644 --- a/litellm/proxy/management_endpoints/auto_router_endpoints.py +++ b/litellm/proxy/management_endpoints/auto_router_endpoints.py @@ -36,6 +36,7 @@ from litellm.proxy.litellm_pre_call_utils import LiteLLMProxyRequestSetup from litellm.repositories.base_repository import SupportsModelDump from litellm.repositories.team_repository import TeamRepository from litellm.router_strategy.complexity_router import ComplexityRouter +from litellm.router_utils.auto_router_model_naming import classify_strategy_router_model from litellm.types.management_endpoints.auto_router_endpoints import ( SHADOW_EVAL_TURN_VALVE, AutoRouterBenchmarkGroup, @@ -510,6 +511,53 @@ def _summed_agg_row(rows: Sequence[_SessionAggRow]) -> _SessionAggRow: ) +def _strategy_router_key(deployment: object) -> tuple[str, str] | None: + """``(model_name, kind)`` for a deployment whose routing the session rollup records. + + Kinds come from ``classify_strategy_router_model``, the same rule the Router registers a + deployment by, so this arm cannot disagree with the arm that stamped ``router_type`` onto + the session rows. Semantic auto-routers return None: they record no routing decision, so + they can never own a session row, and ``AutoRouterBenchmarkGroup.router_type`` has no + value for them. A permanent zero would read as "no traffic" rather than "not instrumented". + """ + if not isinstance(deployment, Mapping): + return None + litellm_params: Final = deployment.get("litellm_params") + router_name: Final = deployment.get("model_name") + if not (isinstance(litellm_params, Mapping) and isinstance(router_name, str) and router_name): + return None + model: Final = litellm_params.get("model") + if not isinstance(model, str): + return None + kind: Final = classify_strategy_router_model(model) + return None if kind is None or kind == "semantic" else (router_name, kind) + + +def _idle_router_groups( + llm_router: "Router | None", covered: frozenset[tuple[str, str]] +) -> tuple[AutoRouterBenchmarkGroup, ...]: + """Zeroed groups for configured strategy routers the window's traffic did not cover. + + The dashboard's router picker has to list a router the moment it is created rather than + once it has spent something, so the registry drives the list and the rollup only supplies + the measures. ``_summed_agg_row`` over no sessions is already the zero element of the + fold, so a group with every measure at zero costs one relabel rather than a literal that + would go stale the next time the response grows a field. + """ + if llm_router is None: + return () + zero: Final = _summed_agg_row(()) + idle: Final = frozenset( + key + for key in (_strategy_router_key(deployment) for deployment in llm_router.model_list or ()) + if key is not None and key not in covered + ) + return tuple( + _benchmark_group(zero.model_copy(update=MappingProxyType({"router_name": name, "router_type": kind}))) + for name, kind in sorted(idle) + ) + + @router.get( "/auto_router/benchmarks", tags=("auto router",), @@ -532,8 +580,13 @@ async def get_auto_router_benchmarks( overlaps it: its last turn is on or after start_date and its first turn is on or before end_date. Overall hit rate is over telemetry-bearing turns; each bucket's hit rate is over that bucket's turns. + + The rollup supplies the measures, never the list. Which routers appear comes from the + model registry, so one shows up as soon as it is configured and reads zero until it + serves traffic, and `routers_in_scope` counts those too rather than only the routers the + window recorded. """ - from litellm.proxy.proxy_server import prisma_client + from litellm.proxy.proxy_server import llm_router, prisma_client _require_admin_viewer(user_api_key_dict, "view auto-router benchmarks across the deployment") if prisma_client is None: @@ -555,11 +608,14 @@ async def get_auto_router_benchmarks( (end_day + timedelta(days=1)).isoformat(), ) rows: Final = _SESSION_AGG_ROWS.validate_python(raw_rows or ()) - groups: Final = tuple(_benchmark_group(row) for row in rows) + groups: Final = ( + *(_benchmark_group(row) for row in rows), + *_idle_router_groups(llm_router, frozenset((row.router_name, row.router_type) for row in rows)), + ) return AutoRouterBenchmarksResponse( start_date=start_day.strftime("%Y-%m-%d"), end_date=end_day.strftime("%Y-%m-%d"), - routers_in_scope=len(rows), + routers_in_scope=len(groups), totals=_benchmark_totals(_summed_agg_row(rows)), groups=groups, ) diff --git a/litellm/types/management_endpoints/auto_router_endpoints.py b/litellm/types/management_endpoints/auto_router_endpoints.py index e2469d4c78f..a88ffeec6b5 100644 --- a/litellm/types/management_endpoints/auto_router_endpoints.py +++ b/litellm/types/management_endpoints/auto_router_endpoints.py @@ -158,9 +158,18 @@ class AutoRouterBenchmarksResponse(BaseModel): start_date: str = Field(description="Window start day, YYYY-MM-DD UTC, inclusive") end_date: str = Field(description="Window end day, YYYY-MM-DD UTC, inclusive") - routers_in_scope: int + routers_in_scope: int = Field( + description="How many groups this response carries. Every auto-router configured on the " + "proxy counts, whether or not it served anything in the window. To count only the routers " + "that did serve traffic, filter `groups` to the entries whose `sessions` is above zero" + ) totals: AutoRouterBenchmarkTotals - groups: tuple[AutoRouterBenchmarkGroup, ...] + groups: tuple[AutoRouterBenchmarkGroup, ...] = Field( + description="One entry per auto-router, listed from the model registry rather than from " + "the rollup, so a router appears as soon as it is configured and reads zero until it " + "serves traffic. Semantic auto-routers are absent: they record no routing decision, so no " + "session can ever be attributed to them" + ) ShadowEvalStatus: TypeAlias = Literal["running", "completed", "stopped"] diff --git a/tests/test_litellm/proxy/management_endpoints/test_auto_router_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_auto_router_endpoints.py index 805168c84ac..3a0279ab0fa 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_auto_router_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_auto_router_endpoints.py @@ -2,6 +2,7 @@ Unit tests for auto router management endpoints """ +from collections.abc import Mapping, Sequence from pathlib import Path from typing import Final @@ -22,11 +23,22 @@ from litellm.proxy.management_endpoints.auto_router_endpoints import ( from litellm.router import Router from litellm.types.utils import Choices, Message, ModelResponse from litellm.types.management_endpoints.auto_router_endpoints import ( + AutoRouterBenchmarksResponse, AutoRouterRoutingTestRequest, ) ADMIN = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, api_key="sk-test", user_id="admin") + +def _deployment(model_name: str, model: str, *, db_model: bool) -> dict[str, object]: + """One entry as `Router.model_list` holds it, for either origin.""" + return { + "model_name": model_name, + "litellm_params": {"model": model}, + "model_info": {"id": f"{model_name}-{int(db_model)}", "db_model": db_model}, + } + + TIERS = { "SIMPLE": ["cheap-model"], "MEDIUM": ["mid-model"], @@ -295,6 +307,34 @@ def test_classifier_plugin_is_not_settable_over_http(): class TestAutoRouterBenchmarks: from litellm.proxy.management_endpoints.auto_router_endpoints import _SessionAggRow + @pytest.fixture(autouse=True) + def _pin_the_router_global(self, monkeypatch: pytest.MonkeyPatch): + """Every test here reads proxy_server.llm_router, so no test may inherit a sibling's.""" + from litellm.proxy import proxy_server + + monkeypatch.setattr(proxy_server, "llm_router", None) + + @staticmethod + async def _benchmarks( + monkeypatch: pytest.MonkeyPatch, + rows: Sequence[Mapping[str, object]], + model_list: Sequence[object], + ) -> AutoRouterBenchmarksResponse: + from litellm.proxy import proxy_server + from litellm.proxy.management_endpoints.auto_router_endpoints import get_auto_router_benchmarks + + class _DB: + async def query_raw(self, sql: str, *params: object): + return rows + + monkeypatch.setattr(proxy_server, "prisma_client", type("P", (), {"db": _DB()})()) + monkeypatch.setattr(proxy_server, "llm_router", type("R", (), {"model_list": model_list})()) + return await get_auto_router_benchmarks( + user_api_key_dict=ADMIN, + start_date="2026-07-01", + end_date="2026-08-01", + ) + ROW = _SessionAggRow( router_name="live-auto", router_type="complexity", @@ -471,6 +511,113 @@ class TestAutoRouterBenchmarks: ) assert response.groups[0].tier_turns == expected + @pytest.mark.asyncio + async def test_the_picker_lists_configured_routers_before_they_have_traffic(self, monkeypatch: pytest.MonkeyPatch): + """A router must be selectable the moment it exists, from either origin. + + `live-auto` is the only router the rollup knows about, so before this it was the only + thing the dropdown could offer. Both a config.yaml router and a DB-created one now + arrive zeroed, and neither moves the totals or duplicates the router that has traffic. + """ + response = await self._benchmarks( + monkeypatch, + rows=[self.ROW.model_dump()], + model_list=[ + _deployment("live-auto", "auto_router/complexity_router", db_model=False), + _deployment("idle-from-config", "auto_router/complexity_router", db_model=False), + _deployment("idle-from-db", "auto_router/complexity_router", db_model=True), + ], + ) + + by_name = {group.router_name: group for group in response.groups} + assert sorted(by_name) == ["idle-from-config", "idle-from-db", "live-auto"] + assert len(response.groups) == 3 + assert response.routers_in_scope == 3 + assert by_name["live-auto"].spend == 10.0 + assert response.totals.spend == 10.0 + assert response.totals.sessions == 4 + for name in ("idle-from-config", "idle-from-db"): + idle = by_name[name] + assert idle.router_type == "complexity" + assert (idle.sessions, idle.turns, idle.spend, idle.saved_spend, idle.baseline_spend) == ( + 0, + 0, + 0.0, + 0.0, + 0.0, + ) + assert (idle.saved_pct, idle.saved_per_session, idle.avg_turns_per_session) == (0.0, 0.0, 0.0) + assert (idle.cache.hit_rate_pct, idle.cache.coverage_pct) == (0.0, 0.0) + assert idle.cache.same_model.turns == idle.cache.return_to_tier.hits == 0 + assert idle.tier_turns == {} + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "model, listed_as", + [ + ("auto_router/complexity_router", "complexity"), + ("auto_router/adaptive_router", "adaptive"), + ("auto_router/quality_router", "quality"), + ("auto_router/my-semantic-router", None), + ("openai/gpt-5", None), + ], + ) + async def test_only_kinds_whose_routing_the_rollup_records_are_listed( + self, model: str, listed_as: str | None, monkeypatch: pytest.MonkeyPatch + ): + """A semantic auto-router records no routing decision, so it can never own a session + row; listing it would show $0 forever even while it serves traffic.""" + response = await self._benchmarks( + monkeypatch, rows=[], model_list=[_deployment("candidate", model, db_model=True)] + ) + + assert [group.router_type for group in response.groups] == ([listed_as] if listed_as else []) + + @pytest.mark.asyncio + async def test_a_malformed_deployment_is_skipped_rather_than_failing_the_dashboard( + self, monkeypatch: pytest.MonkeyPatch + ): + response = await self._benchmarks( + monkeypatch, + rows=[self.ROW.model_dump()], + model_list=[ + "not-a-mapping", + {}, + {"model_name": "no-params"}, + {"model_name": "", "litellm_params": {"model": "auto_router/complexity_router"}}, + {"model_name": 7, "litellm_params": {"model": "auto_router/complexity_router"}}, + {"model_name": "no-model", "litellm_params": {}}, + {"model_name": "unreadable-model", "litellm_params": {"model": None}}, + ], + ) + + assert [group.router_name for group in response.groups] == ["live-auto"] + + @pytest.mark.asyncio + async def test_two_deployments_of_one_router_are_listed_once(self, monkeypatch: pytest.MonkeyPatch): + """Tagged variants share a model_name, and the picker selects by name and type.""" + response = await self._benchmarks( + monkeypatch, + rows=[], + model_list=[ + _deployment("tagged", "auto_router/complexity_router", db_model=True), + _deployment("tagged", "auto_router/complexity_router", db_model=True), + ], + ) + + assert [group.router_name for group in response.groups] == ["tagged"] + + def test_the_listed_kinds_match_the_router_types_traffic_can_record(self): + """The one reason semantic is excluded, pinned against both declarations: a kind the + rollup can record must be listable, and a kind it cannot must not be.""" + from typing import get_args, get_type_hints + + from litellm.router_utils.auto_router_model_naming import StrategyRouterKind + from litellm.types.utils import StandardLoggingRoutingDecision + + recorded = set(get_args(get_type_hints(StandardLoggingRoutingDecision)["router_type"])) + assert set(get_args(StrategyRouterKind)) - {"semantic"} == recorded + # --------------------------------------------------------------------------- # Shadow eval endpoints diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 90529eaec48..f4731946492 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -777,6 +777,11 @@ export interface paths { * overlaps it: its last turn is on or after start_date and its first turn is on or before * end_date. Overall hit rate is over telemetry-bearing turns; each bucket's hit rate is * over that bucket's turns. + * + * The rollup supplies the measures, never the list. Which routers appear comes from the + * model registry, so one shows up as soon as it is configured and reads zero until it + * serves traffic, and `routers_in_scope` counts those too rather than only the routers the + * window recorded. */ get: operations["get_auto_router_benchmarks_auto_router_benchmarks_get"]; put?: never; @@ -21965,9 +21970,15 @@ export interface components { * @description Window end day, YYYY-MM-DD UTC, inclusive */ end_date: string; - /** Groups */ + /** + * Groups + * @description One entry per auto-router, listed from the model registry rather than from the rollup, so a router appears as soon as it is configured and reads zero until it serves traffic. Semantic auto-routers are absent: they record no routing decision, so no session can ever be attributed to them + */ groups: components["schemas"]["AutoRouterBenchmarkGroup"][]; - /** Routers In Scope */ + /** + * Routers In Scope + * @description How many groups this response carries. Every auto-router configured on the proxy counts, whether or not it served anything in the window. To count only the routers that did serve traffic, filter `groups` to the entries whose `sessions` is above zero + */ routers_in_scope: number; /** * Start Date From ee0363249d83576c13ba8dc027bd7a7be94fa11a Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Mon, 24 Aug 2026 12:28:46 -0700 Subject: [PATCH 06/20] fix(vertex-passthrough): strip virtual key sent via custom key header user_api_key_auth also authenticates a caller from the operator-configured general_settings.litellm_key_header_name, reading that header straight off the request, so a virtual key sent there survived the credential-less Vertex forwarding filter and reached Google alongside a real bring-your-own credential. Value-strip every header whose value matches the caller's key from any accepted source, including that custom header. --- .../llm_passthrough_endpoints.py | 44 +++++++++++++------ .../test_llm_pass_through_endpoints.py | 40 ++++++++++++++++- 2 files changed, 70 insertions(+), 14 deletions(-) diff --git a/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py b/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py index 75e3baf2c4a..99104040831 100644 --- a/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py +++ b/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py @@ -1747,31 +1747,49 @@ _HEADERS_NEVER_FORWARDED_TO_VERTEX: Final = frozenset( ) +def _credentialless_caller_key_values(request: Request) -> frozenset[str]: + """Every header value the proxy would accept as this caller's LiteLLM key. + + Beyond the built-in ``x-litellm-api-key`` / ``Authorization`` that + ``get_litellm_virtual_key`` reads, ``user_api_key_auth`` also authenticates a + caller from the operator-configured ``general_settings.litellm_key_header_name`` + when one is set, reading that header straight off the request. Any of those + values equals the virtual key and must never be forwarded to Google. + """ + from litellm.proxy.proxy_server import general_settings + + custom_key_header_name: Final = general_settings.get("litellm_key_header_name") or "" + candidates: Final = ( + get_litellm_virtual_key(request), + request.headers.get(custom_key_header_name, "") if custom_key_header_name else "", + ) + return frozenset(_bearer_stripped(value) for value in candidates if _bearer_stripped(value)) + + def _forwarded_headers_for_credentialless_vertex_passthrough(request: Request) -> Mapping[str, str]: """ Header set to forward on the bring-your-own-credentials Vertex passthrough branch, used when the proxy has no Vertex credential configured. No credential the proxy accepts for caller authentication is forwarded to - Google. LiteLLM reads the caller's virtual key from ``x-litellm-api-key``, - ``api-key``, ``x-api-key``, ``Authorization``, and ``x-goog-api-key``. Vertex - only ever authenticates with an OAuth token in ``Authorization`` or an API key - in ``x-goog-api-key``, so ``x-litellm-api-key`` / ``api-key`` / ``x-api-key`` - can only carry caller auth material and are dropped by name. ``Authorization`` - and ``x-goog-api-key`` may instead carry a genuine bring-your-own Google - credential, so they are kept unless their value is the caller's virtual key, - which is dropped by value (normalizing any ``Bearer`` prefix). When neither a - surviving ``Authorization`` nor ``x-goog-api-key`` remains the request is - rejected so the virtual key cannot leak upstream. + Google. Vertex only ever authenticates with an OAuth token in ``Authorization`` + or an API key in ``x-goog-api-key``, so the proxy-only auth headers Google never + consumes (``x-litellm-api-key`` / ``api-key`` / ``x-api-key``) are dropped by + name. ``Authorization`` and ``x-goog-api-key`` may instead carry a genuine + bring-your-own Google credential, so they are kept unless their value is one of + the caller's LiteLLM key values, which are dropped by value (normalizing any + ``Bearer`` prefix). Dropping by value also covers a virtual key sent in the + operator-configured ``litellm_key_header_name``, whatever that header is named. + When neither a surviving ``Authorization`` nor ``x-goog-api-key`` remains the + request is rejected so the virtual key cannot leak upstream. """ incoming: Final = _safe_get_request_headers(request) - caller_virtual_key: Final = _bearer_stripped(get_litellm_virtual_key(request)) + caller_key_values: Final = _credentialless_caller_key_values(request) forwarded: Final = MappingProxyType( { name: value for name, value in incoming.items() - if name not in _HEADERS_NEVER_FORWARDED_TO_VERTEX - and not (caller_virtual_key and _bearer_stripped(value) == caller_virtual_key) + if name not in _HEADERS_NEVER_FORWARDED_TO_VERTEX and _bearer_stripped(value) not in caller_key_values } ) if "authorization" not in forwarded and "x-goog-api-key" not in forwarded: diff --git a/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py b/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py index e268c8cd2b9..c5e56788a96 100644 --- a/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py +++ b/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py @@ -3461,7 +3461,9 @@ class TestVertexCredentiallessPassthroughVirtualKeyLeak: proxy-only auth headers Google never consumes (``x-litellm-api-key``, ``api-key``, ``x-api-key``) are dropped by name, and the virtual key is dropped by value from ``Authorization`` / ``x-goog-api-key``, which may instead carry a - genuine bring-your-own Google credential that must still pass through. + genuine bring-your-own Google credential that must still pass through. The + by-value strip also covers a virtual key sent in the operator-configured + ``general_settings.litellm_key_header_name``, whatever that header is named. """ VKEY = "sk-litellm-victim-key" @@ -3606,6 +3608,42 @@ class TestVertexCredentiallessPassthroughVirtualKeyLeak: assert "azure-style-caller-secret" not in forwarded_blob assert "anthropic-style-caller-secret" not in forwarded_blob + @pytest.mark.asyncio + async def test_virtual_key_in_operator_configured_header_is_stripped(self, monkeypatch): + with mock.patch.dict( # test-quality-ok: general_settings is the real proxy config surface for litellm_key_header_name; no injection seam exists on this route + "litellm.proxy.proxy_server.general_settings", + {"litellm_key_header_name": "x-company-key"}, + ): + raised, forwarded = await self._run( + monkeypatch, + [ + (b"x-company-key", f"Bearer {self.VKEY}".encode()), + (b"x-goog-api-key", b"AIza-real-google-api-key"), + (b"content-type", b"application/json"), + ], + ) + assert raised is None + assert forwarded is not None + assert forwarded.get("x-goog-api-key") == "AIza-real-google-api-key" + assert "x-company-key" not in forwarded + assert self.VKEY not in " ".join(f"{name}:{value}" for name, value in forwarded.items()) + + @pytest.mark.asyncio + async def test_virtual_key_in_operator_configured_header_alone_is_rejected(self, monkeypatch): + with mock.patch.dict( # test-quality-ok: general_settings is the real proxy config surface for litellm_key_header_name; no injection seam exists on this route + "litellm.proxy.proxy_server.general_settings", + {"litellm_key_header_name": "x-company-key"}, + ): + raised, forwarded = await self._run( + monkeypatch, + [ + (b"x-company-key", f"Bearer {self.VKEY}".encode()), + (b"content-type", b"application/json"), + ], + ) + assert forwarded is None, "a virtual key in the custom auth header must not satisfy the gate nor be forwarded" + assert raised is not None and raised.status_code == 401 + class TestGetAzureAISearchIndexFromEndpoint: """The operable index is only the segment right after ``indexes``. From 1c18d3eda3be2a713e83ee2e0c2b1d8b93ef2f53 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Mon, 24 Aug 2026 12:34:27 -0700 Subject: [PATCH 07/20] fix(router): stop copying forwarded credentials into retry breadcrumbs log_retry copied every kwarg into the previous_models breadcrumb, so a client's forwarded Authorization (provider_specific_header) and the deployment api_key / headers rode along in an in-memory structure whose comment says it reaches spend logs and logging callbacks. Those values have no diagnostic use in a breadcrumb. Add provider_specific_header, headers, and api_key to RETRY_BREADCRUMB_EXCLUDED_KWARGS so the credential is never placed there in the first place. This is defense in depth: no persisted leak exists today, since the SpendLogs metadata allowlist and every logging integration already drop previous_models before serialization. Removing the credential at the source means a future logging path cannot expose it either --- litellm/router.py | 16 +++++++++++++--- tests/test_litellm/test_router.py | 24 ++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/litellm/router.py b/litellm/router.py index 045fd32847c..132ff6671a7 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -376,9 +376,19 @@ set_live_deployment_replay(_replay_live_router_model_cost) # Kwargs that log_retry must not copy into a retry breadcrumb. The breadcrumbs reach spend -# logs and logging callbacks, and these carry either the request payload or router-internal -# walk state rather than anything that identifies the failed attempt. -RETRY_BREADCRUMB_EXCLUDED_KWARGS: Final = frozenset(("messages", "original_function", "attempted_targets")) +# logs and logging callbacks, so they must never carry the request payload, router-internal +# walk state, or transport credentials: provider_specific_header / headers / api_key can hold a +# client's forwarded Authorization or a provider key, none of which identify the failed attempt. +RETRY_BREADCRUMB_EXCLUDED_KWARGS: Final = frozenset( + ( + "messages", + "original_function", + "attempted_targets", + "provider_specific_header", + "headers", + "api_key", + ) +) class Router: diff --git a/tests/test_litellm/test_router.py b/tests/test_litellm/test_router.py index d00fbf589e3..7e6cc010834 100644 --- a/tests/test_litellm/test_router.py +++ b/tests/test_litellm/test_router.py @@ -8565,6 +8565,30 @@ async def test_retry_breadcrumbs_do_not_carry_the_walk_state(): assert "attempted_targets" not in breadcrumb +@pytest.mark.asyncio +async def test_retry_breadcrumbs_drop_forwarded_client_credentials(): + """log_retry copies kwargs verbatim into previous_models, which reaches spend logs and logging + callbacks. provider_specific_header can carry a client's forwarded Authorization token, and a + breadcrumb has no diagnostic use for it, so the raw credential must never land in the breadcrumb.""" + canary = "Bearer sk-ant-oat01-RETRY-BREADCRUMB-CANARY-doNotShip" + router = _cyclic_fallback_router(num_retries=1) + capture = _LogCapture(logging.ERROR) + + await _drive_cyclic_fallback( + router, + capture, + provider_specific_header={ + "custom_llm_provider": "openai", + "extra_headers": {"authorization": canary}, + }, + ) + + assert router.previous_models, "no retry breadcrumbs were recorded" + for breadcrumb in router.previous_models: + assert "provider_specific_header" not in breadcrumb + assert canary not in json.dumps(router.previous_models, default=str) + + @pytest.mark.asyncio async def test_fallback_traceback_stays_available_at_debug_level(): """Dropping the stack from the ERROR line is only safe because the fallback path still From ab93636e2c5a16c4e6028151f4b6faa17a4036bd Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Mon, 24 Aug 2026 12:45:41 -0700 Subject: [PATCH 08/20] fix(vertex-passthrough): derive credential-header drop set from SpecialHeaders The hand-rolled drop set missed Ocp-Apim-Subscription-Key, so a caller Azure APIM secret in that header was forwarded to Google on the credential-less branch. Derive the name-drop set from the canonical SpecialHeaders.litellm_credential_header_names(), minus Authorization and x-goog-api-key which double as real Google credentials and are value-stripped instead. New credential headers added there are now dropped automatically. --- .../llm_passthrough_endpoints.py | 14 +++++++---- .../test_llm_pass_through_endpoints.py | 24 ++++++++++++++++++- 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py b/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py index 99104040831..bcdc9b0a68f 100644 --- a/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py +++ b/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py @@ -1742,8 +1742,9 @@ def _bearer_stripped(value: str) -> str: return value -_HEADERS_NEVER_FORWARDED_TO_VERTEX: Final = frozenset( - {"content-length", "host", "x-litellm-api-key", "api-key", "x-api-key"} +_VERTEX_UPSTREAM_CREDENTIAL_HEADERS: Final = frozenset({"authorization", "x-goog-api-key"}) +_HEADERS_NEVER_FORWARDED_TO_VERTEX: Final = frozenset({"content-length", "host"}) | ( + SpecialHeaders.litellm_credential_header_names() - _VERTEX_UPSTREAM_CREDENTIAL_HEADERS ) @@ -1772,9 +1773,12 @@ def _forwarded_headers_for_credentialless_vertex_passthrough(request: Request) - branch, used when the proxy has no Vertex credential configured. No credential the proxy accepts for caller authentication is forwarded to - Google. Vertex only ever authenticates with an OAuth token in ``Authorization`` - or an API key in ``x-goog-api-key``, so the proxy-only auth headers Google never - consumes (``x-litellm-api-key`` / ``api-key`` / ``x-api-key``) are dropped by + Google. ``user_api_key_auth`` reads the caller's key from every header in + ``SpecialHeaders.litellm_credential_header_names()``, and Vertex only ever + authenticates with an OAuth token in ``Authorization`` or an API key in + ``x-goog-api-key``. So the proxy-only auth headers Google never consumes + (everything in that set except those two, e.g. ``x-litellm-api-key`` / + ``api-key`` / ``x-api-key`` / ``Ocp-Apim-Subscription-Key``) are dropped by name. ``Authorization`` and ``x-goog-api-key`` may instead carry a genuine bring-your-own Google credential, so they are kept unless their value is one of the caller's LiteLLM key values, which are dropped by value (normalizing any diff --git a/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py b/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py index c5e56788a96..b65e2b2f499 100644 --- a/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py +++ b/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py @@ -35,7 +35,7 @@ from litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints import ( vertex_proxy_route, vllm_proxy_route, ) -from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth +from litellm.proxy._types import LitellmUserRoles, SpecialHeaders, UserAPIKeyAuth from litellm.types.passthrough_endpoints.vertex_ai import VertexPassThroughCredentials @@ -3608,6 +3608,28 @@ class TestVertexCredentiallessPassthroughVirtualKeyLeak: assert "azure-style-caller-secret" not in forwarded_blob assert "anthropic-style-caller-secret" not in forwarded_blob + @pytest.mark.asyncio + @pytest.mark.parametrize( + "credential_header", + sorted(SpecialHeaders.litellm_credential_header_names() - {"authorization", "x-goog-api-key"}), + ) + async def test_every_non_google_credential_header_is_dropped_by_name(self, monkeypatch, credential_header): + raised, forwarded = await self._run( + monkeypatch, + [ + (b"x-goog-api-key", b"AIza-real-google-api-key"), + (credential_header.encode(), b"some-distinct-caller-secret-value"), + (b"content-type", b"application/json"), + ], + ) + assert raised is None + assert forwarded is not None + assert forwarded.get("x-goog-api-key") == "AIza-real-google-api-key" + assert credential_header not in forwarded + assert "some-distinct-caller-secret-value" not in " ".join( + f"{name}:{value}" for name, value in forwarded.items() + ) + @pytest.mark.asyncio async def test_virtual_key_in_operator_configured_header_is_stripped(self, monkeypatch): with mock.patch.dict( # test-quality-ok: general_settings is the real proxy config surface for litellm_key_header_name; no injection seam exists on this route From ec47bbaaaaf4441922e9991a175c9c33280a9380 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Mon, 24 Aug 2026 12:51:44 -0700 Subject: [PATCH 09/20] feat(e2e): record and replay streamed provider responses chunk-for-chunk The record/replay harness stored a streamed provider response as one buffered body, so a replayed stream arrived coalesced and the /v1/messages streaming test could not be edge-wired. Keep each SSE transfer chunk in the bundle in the order the provider sent it (a new streamed response shape at BUNDLE_FORMAT_VERSION 4) so replay reproduces the provider's split points, the recorded usage chunk keeps its position, and a mid-stream upstream error replays as the same mid-stream error rather than a clean body. Resolves LIT-5742 --- tests/e2e/CLAUDE.md | 6 +- tests/e2e/CONTRIBUTING.md | 2 +- tests/e2e/e2e_http.py | 93 ++++- tests/e2e/fixture_bundle.py | 69 +++- .../e2e/llm_translation/test_messages_e2e.py | 66 ++- tests/e2e/provider_edge.py | 286 +++++++++++-- tests/e2e/test_fixture_bundle.py | 43 ++ tests/e2e/test_provider_edge.py | 380 +++++++++++++++++- 8 files changed, 872 insertions(+), 73 deletions(-) diff --git a/tests/e2e/CLAUDE.md b/tests/e2e/CLAUDE.md index 15bd2c19ca9..e0ee40a65c5 100644 --- a/tests/e2e/CLAUDE.md +++ b/tests/e2e/CLAUDE.md @@ -77,7 +77,7 @@ Mark live tests with `@pytest.mark.e2e` (on the class or the module). Pure cover The seam is `provider_edge.py`: `start_provider_edge` boots an in-process HTTP server (one shared instance per pytest process, `e2e_config.provider_edge_base` is the accessor) that mounts each supported provider under a path prefix (`EDGE_MOUNTS`: `/openai` -> `https://api.openai.com`, `/anthropic` -> `https://api.anthropic.com`). A test participates by registering its deployment with `api_base=provider_edge_base("openai")` plus the provider's path suffix; `quota_management/spend_tracking/test_provider_edge_spend_e2e.py` is the reference. In live mode the accessor returns None and the deployment defaults to the real provider, so an edge-wired test runs in all three modes unchanged. Non-wired tests hit their providers live in every mode. The edge binds `E2E_PROVIDER_EDGE_BIND_HOST` (default 127.0.0.1) and advertises `E2E_PROVIDER_EDGE_ADVERTISE_HOST` in the api_base it hands out, for proxies running in containers -A bundle (default `tests/e2e/.fixtures`, override with `E2E_FIXTURE_DIR`) is a directory: `manifest.json` carries the record timestamp, harness git version, and format version, and each test gets a subdirectory holding one JSON file per provider call in call order (`0000-post-openai-v1-chat-completions.json`). Request headers are never stored (provider credentials never touch disk), non-JSON request bodies store a canonicalized sha256 digest instead of the bytes, `multipart/form-data` bodies store their ordinary fields plus a JSON list of the uploaded parts' `[field, filename, content-type]` triples and a digest of their content, so the per-request random boundary and the envelope never reach the key, and responses store status, filtered headers, and the verbatim body base64-encoded, which is part of why bundles are gitignored. `fixture_bundle.py` owns the format. Record serves the proxy the same filtered stored response replay will serve later, so the two modes are byte-identical from the proxy's side of the socket +A bundle (default `tests/e2e/.fixtures`, override with `E2E_FIXTURE_DIR`) is a directory: `manifest.json` carries the record timestamp, harness git version, and format version, and each test gets a subdirectory holding one JSON file per provider call in call order (`0000-post-openai-v1-chat-completions.json`). Request headers are never stored (provider credentials never touch disk), non-JSON request bodies store a canonicalized sha256 digest instead of the bytes, `multipart/form-data` bodies store their ordinary fields plus a JSON list of the uploaded parts' `[field, filename, content-type]` triples and a digest of their content, so the per-request random boundary and the envelope never reach the key, and responses store status, filtered headers, and the verbatim body base64-encoded, which is part of why bundles are gitignored. Responses come in two shapes told apart by a `kind` tag: an ordinary one holding a single base64 body, and, for a response the provider streamed (`content-type: text/event-stream`), one holding its transfer chunks in order plus why the stream ended early if it did, so replay reproduces the split points the provider chose instead of one coalesced body. `fixture_bundle.py` owns the format, and `BUNDLE_FORMAT_VERSION` is checked on load, so a bundle recorded under older rules is refused by name rather than partially read. Record serves the proxy the same filtered stored response replay will serve later, chunk for chunk on a stream, so the two modes are byte-identical from the proxy's side of the socket Multipart identity is the fiddly corner, and the rules exist because each one had a collision behind it. A part counts as an upload when it carries a filename or declares its own content type, and everything else is an ordinary field. Field names get a `name[n]` suffix on repeats, with a literal `[` doubled first, so a form that repeats `purpose` never keys the same as one that literally sends `purpose[1]`. A field whose name reads as a credential is stored as ``, which stays key-preserving because the key is recomputed from the stored request rather than saved alongside it, so the live request carrying the real value still matches its redacted fixture. A field value that is not UTF-8 is stored as a base64 sha256 digest, base64 and not hex because the canonicalizer rewrites any 64-character hex run to `` and would fold every binary value onto one key. The uploaded parts contribute a JSON list rather than a `field:filename` string, so a separator inside a filename cannot impersonate a field boundary, and their byte length is stored for a reader's benefit but deliberately left out of the key, since the canonicalizer absorbs timestamp and id drift inside a file that changes its length @@ -87,7 +87,7 @@ A replayed response carries the recorded provider response id, and `LiteLLM_Spen The same id reuse reaches the managed-object tables. A replayed `/v1/files` or `/v1/batches` response carries the recorded provider object id, and `LiteLLM_ManagedObjectTable.model_object_id` is unique, so a unified batch create replayed against a database that still holds the record run's row fails on a Prisma unique-constraint violation, which surfaces as a 500, makes the router retry, and exhausts the recording. Replay the batches suite against a fresh database, or truncate `LiteLLM_ManagedObjectTable` and `LiteLLM_ManagedFileTable` before the run -Edge-wired today: `quota_management/spend_tracking/test_provider_edge_spend_e2e.py` (the reference), `llm_translation/test_chat_completions_contract_e2e.py`, the OpenAI registrations in `llm_translation/test_embeddings_endpoint_e2e.py`, the Anthropic deployments in `llm_translation/test_messages_e2e.py` except the streaming test, and the OpenAI batch deployment behind `batches/` (`capabilities.openai_batch_params`). The mount base is not the same for both providers: OpenAI deployments register `f"{base}/v1"`, Anthropic deployments register `base` on its own, because litellm's Anthropic handler appends `/v1/messages` to `api_base` itself where the OpenAI handler appends only `/chat/completions`. Recording one suite locally is two runs against a proxy you already have up: +Edge-wired today: `quota_management/spend_tracking/test_provider_edge_spend_e2e.py` (the reference), `llm_translation/test_chat_completions_contract_e2e.py`, the OpenAI registrations in `llm_translation/test_embeddings_endpoint_e2e.py`, the Anthropic deployments in `llm_translation/test_messages_e2e.py` including the streaming test, and the OpenAI batch deployment behind `batches/` (`capabilities.openai_batch_params`). The mount base is not the same for both providers: OpenAI deployments register `f"{base}/v1"`, Anthropic deployments register `base` on its own, because litellm's Anthropic handler appends `/v1/messages` to `api_base` itself where the OpenAI handler appends only `/chat/completions`. Recording one suite locally is two runs against a proxy you already have up: ```bash E2E_FIXTURE_MODE=record E2E_FIXTURE_DIR=/tmp/e2e-fixtures E2E_RESET_SPEND_LOGS=1 uv run pytest tests/e2e/llm_translation/test_chat_completions_contract_e2e.py @@ -96,7 +96,7 @@ E2E_FIXTURE_MODE=replay E2E_FIXTURE_DIR=/tmp/e2e-fixtures E2E_RESET_SPEND_LOGS=1 Point the proxy at bogus provider credentials for the replay run and it still has to pass: that is the whole proof that nothing left the process. Bundles are never committed. `tests/e2e/.fixtures` is gitignored because a bundle holds verbatim provider response bodies and hard-fails after seven days, and publishing one for CI is LIT-5748 -Current limits: streaming chunk fidelity is LIT-5742 (a streamed response records as one buffered body), CI wiring is LIT-5748, Bedrock cannot be mounted (SigV4 signs the Host header, so a rewritten api_base fails signature verification), deployments baked into the proxy's config file cannot be edge-wired (only `/model/new` registrations can carry the edge api_base), and a file upload routed by `custom_llm_provider` through the proxy's `files_settings` block never passes a deployment at all, so the batches `model_param` and `provider_fallback` scenarios keep uploading live in every mode +Current limits: CI wiring is LIT-5748, Bedrock cannot be mounted (SigV4 signs the Host header, so a rewritten api_base fails signature verification), deployments baked into the proxy's config file cannot be edge-wired (only `/model/new` registrations can carry the edge api_base), and a file upload routed by `custom_llm_provider` through the proxy's `files_settings` block never passes a deployment at all, so the batches `model_param` and `provider_fallback` scenarios keep uploading live in every mode ## Typing diff --git a/tests/e2e/CONTRIBUTING.md b/tests/e2e/CONTRIBUTING.md index 29778b06d7a..75261b301bd 100644 --- a/tests/e2e/CONTRIBUTING.md +++ b/tests/e2e/CONTRIBUTING.md @@ -65,7 +65,7 @@ Bundles stay local. `tests/e2e/.fixtures` is gitignored because a bundle holds v One sharp edge: a replayed response reuses the recorded provider response id, and that id is the primary key of `LiteLLM_SpendLogs`, so replaying against a database that still holds the record run's rows silently dedupes the spend writes and a spend assertion fails with zero rows. Run both commands above with `E2E_RESET_SPEND_LOGS=1` (and `DATABASE_URL` set in the pytest env) so each session truncates the spend log table after itself, or point replay at a fresh database -Replay answers any provider call that drifted from the recording with an HTTP 599 whose body names the computed and closest recorded keys, so the test fails loudly instead of silently going live, and a bundle older than seven days fails at collection time naming its age; either way the fix is to re-record. Only tests that register edge-wired deployments participate: everything else hits its provider live in every mode, so record exactly the suite you replay. If the proxy runs in a container, set `E2E_PROVIDER_EDGE_ADVERTISE_HOST` (e.g. `host.docker.internal`) so the api_base the proxy stores can reach the edge on the pytest host, and `E2E_PROVIDER_EDGE_BIND_HOST=0.0.0.0` so the edge accepts it. The suites wired to the edge today are `quota_management/spend_tracking/test_provider_edge_spend_e2e.py`, `llm_translation/test_chat_completions_contract_e2e.py`, the OpenAI registrations in `llm_translation/test_embeddings_endpoint_e2e.py`, the non-streaming Anthropic tests in `llm_translation/test_messages_e2e.py`, and the OpenAI batch deployment behind `batches/`. See `CLAUDE.md` in this directory for the bundle format, the edge design, and the current limits (streaming, Bedrock) +Replay answers any provider call that drifted from the recording with an HTTP 599 whose body names the computed and closest recorded keys, so the test fails loudly instead of silently going live, and a bundle older than seven days fails at collection time naming its age; either way the fix is to re-record. Only tests that register edge-wired deployments participate: everything else hits its provider live in every mode, so record exactly the suite you replay. If the proxy runs in a container, set `E2E_PROVIDER_EDGE_ADVERTISE_HOST` (e.g. `host.docker.internal`) so the api_base the proxy stores can reach the edge on the pytest host, and `E2E_PROVIDER_EDGE_BIND_HOST=0.0.0.0` so the edge accepts it. The suites wired to the edge today are `quota_management/spend_tracking/test_provider_edge_spend_e2e.py`, `llm_translation/test_chat_completions_contract_e2e.py`, the OpenAI registrations in `llm_translation/test_embeddings_endpoint_e2e.py`, the Anthropic tests in `llm_translation/test_messages_e2e.py`, streamed and not, and the OpenAI batch deployment behind `batches/`. A streamed response replays as the chunk sequence the provider sent rather than one buffered body. See `CLAUDE.md` in this directory for the bundle format, the edge design, and the current limits (Bedrock, CI wiring) Tests marked `@pytest.mark.e2e` hard-fail when no proxy answers `/health/liveliness`, so a run that goes red with `No live proxy` at setup means the proxy isn't up; they never skip for a missing proxy, so an absent proxy can't be mistaken for a pass diff --git a/tests/e2e/e2e_http.py b/tests/e2e/e2e_http.py index 03f201e946e..bc76eb3ea7a 100644 --- a/tests/e2e/e2e_http.py +++ b/tests/e2e/e2e_http.py @@ -4,6 +4,11 @@ Enforced by tests/code_coverage_tests/check_e2e_no_raw_requests.py. Every reques body / query / header / response is a pydantic model; outcomes are a tagged union (``Result[R]``) so callers ``match`` on them instead of catching exceptions. +``forward`` relays one provider-bound request for the provider edge and buffers +the whole body; ``forward_stream`` relays the same request but hands back the +response head plus a lazy iterator over the upstream's own transfer chunks, which +is what lets a recording keep the split points a streamed response arrived on. + Named e2e_http (not http) so it does not shadow the stdlib ``http`` package that requests itself imports. """ @@ -12,7 +17,8 @@ from __future__ import annotations import time from collections.abc import Callable -from typing import Generic, Iterator, Literal, NewType, Protocol, TypeVar, cast +from dataclasses import dataclass +from typing import Generator, Generic, Iterator, Literal, NewType, Protocol, TypeVar, cast import pytest import requests @@ -681,3 +687,88 @@ def forward( headers={name.lower(): value for name, value in resp.headers.items()}, body=resp.content, ) + + +@dataclass(frozen=True, slots=True) +class StreamChunk: + """One transfer chunk of a response body, exactly as the upstream framed it.""" + + data: bytes + + +@dataclass(frozen=True, slots=True) +class StreamTruncation: + """The body ended without its terminator, i.e. the upstream hung up mid-message. + Always the last step, and ``reason`` is the transport's own description of it.""" + + reason: str + + +type StreamStep = StreamChunk | StreamTruncation + + +@dataclass(frozen=True, slots=True) +class StreamHead: + """An upstream response whose head has arrived and whose body has not been read. + + A dataclass rather than a BaseModel because it owns a live socket: ``steps`` is + consumed once, in order, and closing it closes the underlying response.""" + + status_code: int + headers: dict[str, str] + steps: Generator[StreamStep, None, None] + + +def _stream_steps(resp: requests.Response) -> Generator[StreamStep, None, None]: + """The body as the upstream framed it, one step per transfer chunk. + + ``chunk_size=None`` is the whole point: urllib3 then returns exactly one piece + per wire chunk, so the provider's split points survive into the recording. Any + integer would re-slice the body into fixed-size pieces instead. Empty pieces are + dropped because a zero-length chunk is the terminator on the wire, and a failure + part way through becomes a final truncation step rather than an exception, since + the chunks already delivered are exactly what makes a mid-stream failure + different from a request that never streamed at all.""" + try: + for piece in cast("Iterator[bytes]", resp.iter_content(chunk_size=None)): + if piece: + yield StreamChunk(data=piece) + except requests.RequestException as exc: + yield StreamTruncation(reason=str(exc)) + finally: + resp.close() + + +def forward_stream( + method: str, + url: str, + *, + headers: dict[str, str], + body: bytes | None, + timeout: float = 60.0, +) -> StreamHead | NetworkError: + """Relay one provider-bound request for the provider edge and return as soon as + the response head arrives, with the body left unread behind ``StreamHead.steps``. + + Same contract as ``forward`` otherwise: no retries, no redirects, no schema. A + failure before the head arrives is still a ``NetworkError``; one raised while the + body streams arrives as the last step. With ``stream=True`` the timeout bounds + each socket read rather than the whole body, which is the right bound for a + stream and strictly more permissive for a long generation.""" + try: + resp = requests.request( + method, + url, + headers=headers, + data=body, + timeout=timeout, + allow_redirects=False, + stream=True, + ) + except requests.RequestException as exc: + return NetworkError(message=str(exc)) + return StreamHead( + status_code=resp.status_code, + headers={name.lower(): value for name, value in resp.headers.items()}, + steps=_stream_steps(resp), + ) diff --git a/tests/e2e/fixture_bundle.py b/tests/e2e/fixture_bundle.py index aa0ba100b6c..7c9dab1a687 100644 --- a/tests/e2e/fixture_bundle.py +++ b/tests/e2e/fixture_bundle.py @@ -6,15 +6,17 @@ per provider-bound interaction in call order. Bundles older than ``MAX_BUNDLE_AGE`` hard-fail replay at collection time (see conftest), so a green replay run can never certify against fixtures that have drifted more than a week from the live providers. Bump ``BUNDLE_FORMAT_VERSION`` whenever a change -moves recorded keys: a bundle recorded under the old rules then fails naming -both versions instead of quietly missing on every call. +moves recorded keys or changes the stored shape: a bundle recorded under the old +rules then fails naming both versions instead of quietly missing on every call. This module owns the format only. The provider-edge server that produces and -consumes it lives in provider_edge.py (LIT-5745) and the canonical match keys -it computes live in fixture_canonical.py (LIT-5741); streaming chunk fidelity -is a follow-up (LIT-5742). Every interaction file stores the full redacted -request because replay matches on its canonicalized content, and the response -as the raw HTTP status, filtered headers, and base64 body the provider sent. +consumes it lives in provider_edge.py (LIT-5745) and the canonical match keys it +computes live in fixture_canonical.py (LIT-5741). Every interaction file stores +the full redacted request because replay matches on its canonicalized content, +and a response in one of two shapes, told apart by their ``kind`` tag: an +ordinary ``RecordedHttpResponse`` holding one base64 body, or, for a response the +provider streamed, a ``RecordedStreamedResponse`` holding its transfer chunks in +order so replay reproduces the same split points (LIT-5742). """ from __future__ import annotations @@ -26,11 +28,11 @@ import subprocess from dataclasses import dataclass, field from datetime import datetime, timedelta, timezone from pathlib import Path -from typing import Final +from typing import Annotated, Final, Literal -from pydantic import BaseModel, JsonValue +from pydantic import BaseModel, Field, JsonValue -BUNDLE_FORMAT_VERSION: Final = 3 +BUNDLE_FORMAT_VERSION: Final = 4 MAX_BUNDLE_AGE: Final = timedelta(days=7) MANIFEST_FILENAME: Final = "manifest.json" @@ -74,14 +76,38 @@ class RecordedHttpResponse(BaseModel): volatile entries (see provider_edge.py), and the body as base64 so binary payloads survive JSON.""" + kind: Literal["http"] = "http" status_code: int headers: dict[str, str] body_b64: str +class RecordedStreamedResponse(BaseModel): + """A response the provider streamed, kept chunk by chunk instead of buffered. + + ``chunks_b64`` holds one entry per upstream transfer chunk, in order, so replay + reproduces the split points the provider chose rather than one coalesced body. + ``truncated`` is None for a stream that reached its terminator and otherwise + says why it did not, prefixed by which side ended it (``upstream:`` for a + provider that hung up mid-stream, ``downstream:`` for a proxy that stopped + reading). Replay behaves the same for any truncation, delivering the recorded + chunks and then closing; the reason is there for whoever reads the bundle.""" + + kind: Literal["streamed"] = "streamed" + status_code: int + headers: dict[str, str] + chunks_b64: list[str] + truncated: str | None = None + + +type RecordedResponse = Annotated[ + RecordedHttpResponse | RecordedStreamedResponse, Field(discriminator="kind") +] + + class Interaction(BaseModel): request: RecordedRequest - response: RecordedHttpResponse + response: RecordedResponse def slugify(raw: str, *, limit: int = 60) -> str: @@ -128,7 +154,7 @@ class BundleRecorder: root: Path _ordinals: dict[str, int] = field(default_factory=dict) - def record(self, *, test_key: str, request: RecordedRequest, response: RecordedHttpResponse) -> None: + def record(self, *, test_key: str, request: RecordedRequest, response: RecordedResponse) -> None: slug = slug_for_test(test_key) ordinal = self._ordinals.get(slug, 0) self._ordinals[slug] = ordinal + 1 @@ -200,14 +226,27 @@ def _read_manifest(root: Path) -> Manifest | UnreadableBundle: return UnreadableBundle(reason=f"{MANIFEST_FILENAME} is invalid: {exc}") -def check_freshness(root: Path, *, now: datetime) -> BundleFreshness: +def _supported_manifest(root: Path) -> Manifest | UnreadableBundle: + """The manifest, refused when it was written under a different format version. + A bundle is atomic (record wipes and rewrites the whole directory and never + merges), so a foreign version is a hard reject rather than a partial read.""" manifest = _read_manifest(root) if isinstance(manifest, UnreadableBundle): return manifest if manifest.format_version != BUNDLE_FORMAT_VERSION: return UnreadableBundle( - reason=f"format_version {manifest.format_version} != supported {BUNDLE_FORMAT_VERSION}" + reason=( + f"format_version {manifest.format_version} != supported {BUNDLE_FORMAT_VERSION}; " + "re-record with E2E_FIXTURE_MODE=record" + ) ) + return manifest + + +def check_freshness(root: Path, *, now: datetime) -> BundleFreshness: + manifest = _supported_manifest(root) + if isinstance(manifest, UnreadableBundle): + return manifest recorded_at = ( manifest.recorded_at if manifest.recorded_at.tzinfo is not None @@ -231,7 +270,7 @@ class LoadedBundle: def load_bundle(root: Path) -> LoadedBundle | UnreadableBundle: - manifest = _read_manifest(root) + manifest = _supported_manifest(root) if isinstance(manifest, UnreadableBundle): return manifest interactions = { diff --git a/tests/e2e/llm_translation/test_messages_e2e.py b/tests/e2e/llm_translation/test_messages_e2e.py index 7f81a5e3946..d43446e4b5a 100644 --- a/tests/e2e/llm_translation/test_messages_e2e.py +++ b/tests/e2e/llm_translation/test_messages_e2e.py @@ -33,6 +33,25 @@ class _OptionalMessagesBody(BaseModel): max_tokens: int | None = None +class _MessagesEventDelta(BaseModel): + text: str = "" + + +class _MessagesEventUsage(BaseModel): + output_tokens: int | None = None + + +class _MessagesStreamEvent(BaseModel): + """One Anthropic SSE event, keeping only what the stream's shape is asserted on. + + ``delta.text`` is populated on ``content_block_delta`` and absent on the + ``message_delta`` that closes the turn, which is the event carrying ``usage``.""" + + type: str + delta: _MessagesEventDelta | None = None + usage: _MessagesEventUsage | None = None + + ANTHROPIC_BACKEND = "anthropic/claude-haiku-4-5" WEATHER_TOOL = AnthropicCustomTool( @@ -137,13 +156,14 @@ class TestAnthropicMessages: def test_messages_streams_completion( self, endpoints_client: EndpointsClient, resources: ResourceManager ) -> None: - """Stays on a live Anthropic deployment in every mode: the edge buffers a - streamed response into one body, so chunk fidelity waits on LIT-5742.""" - model, key = self._register( - endpoints_client, - resources, - LiteLLMParamsBody(model=ANTHROPIC_BACKEND, api_key="os.environ/ANTHROPIC_API_KEY"), - ) + """Edge-wired like its non-streaming siblings, so record and replay both + carry the streamed response. + + Asserts the shape of the event sequence, not just that deltas and a stop + appeared somewhere in it: the answer arrives across several deltas, and the + usage event sits between the last of them and ``message_stop``. A replay that + coalesced the response into one buffered body could not satisfy either.""" + model, key = self._register(endpoints_client, resources) result = endpoints_client.proxy.messages_stream( key, @@ -158,11 +178,35 @@ class TestAnthropicMessages: assert result.is_streaming, f"response was not streamed: {result.headers}" assert not result.stream_error, f"stream errored: {result.stream_error}" assert result.stream_events, "stream produced no SSE events" - assert any("content_block_delta" in event for event in result.stream_events), ( - "stream carried no content deltas" + + events = [ + _MessagesStreamEvent.model_validate_json(event) for event in result.stream_events + ] + types = [event.type for event in events] + delta_positions = [ + index for index, event in enumerate(events) if event.type == "content_block_delta" + ] + assert len(delta_positions) >= 2, ( + f"stream carried {len(delta_positions)} content deltas, so it was not " + f"incremental: {types}" ) - assert any("message_stop" in event for event in result.stream_events), ( - "stream never reached message_stop" + text = "".join( + event.delta.text + for event in events + if event.type == "content_block_delta" and event.delta is not None + ) + assert text.strip(), f"content deltas assembled to no text: {result.stream_events[:5]}" + + usage_positions = [ + index + for index, event in enumerate(events) + if event.type == "message_delta" and event.usage is not None + ] + assert usage_positions, f"stream never reported usage: {types}" + assert "message_stop" in types, f"stream never reached message_stop: {types}" + stop_position = types.index("message_stop") + assert delta_positions[-1] < usage_positions[0] < stop_position, ( + f"usage did not land between the last content delta and message_stop: {types}" ) @pytest.mark.covers("llm.messages.anthropic.tool_use.nonstream.works") diff --git a/tests/e2e/provider_edge.py b/tests/e2e/provider_edge.py index 25a1e8043ed..6cedf633a3e 100644 --- a/tests/e2e/provider_edge.py +++ b/tests/e2e/provider_edge.py @@ -19,10 +19,21 @@ headers must never touch disk. An unmatched replay call returns HTTP ``REPLAY_MISS_STATUS`` naming the closest recorded interaction, which the proxy relays as a provider error the failing test surfaces. +A response the provider streamed (one whose content type names +``text/event-stream``) is relayed and stored chunk by chunk instead of buffered +(LIT-5742): the edge reads one piece per upstream transfer chunk, writes each +one downstream in chunked framing as it arrives, and records the sequence, so +replay hands the proxy the same number of chunks split in the same places. A +provider that hangs up mid-stream is recorded as the chunks it did deliver plus +a truncation, and replays as those chunks followed by a connection close with no +terminator, which is the same incomplete chunked read the live failure produced +rather than a clean 502 that erases it. Everything else keeps the buffered +shape, byte for byte, framed with a content-length as before. + v1 limits: only the mounts in ``EDGE_MOUNTS`` (SigV4 providers like Bedrock -sign the Host header, so a forwarding edge breaks their signatures), streaming -fidelity is LIT-5742, and CI wiring is LIT-5748. Suites that do not wire the -edge keep hitting providers live in every mode. +sign the Host header, so a forwarding edge breaks their signatures), and CI +wiring is LIT-5748. Suites that do not wire the edge keep hitting providers +live in every mode. """ from __future__ import annotations @@ -34,24 +45,34 @@ import hashlib import re import threading from collections import deque -from collections.abc import Mapping +from collections.abc import Mapping, Sequence +from contextlib import closing from dataclasses import dataclass, field from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from itertools import islice from pathlib import Path from types import MappingProxyType -from typing import Final, Literal, assert_never +from typing import Final, Generator, Literal, assert_never from urllib.parse import parse_qsl, urlsplit from pydantic import JsonValue, TypeAdapter -from e2e_http import NetworkError, RawResponse, forward +from e2e_http import ( + NetworkError, + StreamChunk, + StreamHead, + StreamStep, + StreamTruncation, + forward_stream, +) from fixture_bundle import ( BundleRecorder, Interaction, LoadedBundle, RecordedHttpResponse, RecordedRequest, + RecordedResponse, + RecordedStreamedResponse, UnreadableBundle, UnsafeBundleDir, interaction_filename, @@ -479,11 +500,28 @@ type EdgeBackend = RecordEdge | ReplayEdge @dataclass(frozen=True, slots=True) class EdgeReply: + """A whole response the edge already holds: written with a content-length.""" + status_code: int headers: dict[str, str] body: bytes +@dataclass(frozen=True, slots=True) +class EdgeStream: + """A response the edge relays chunk by chunk: written in chunked framing, one + transfer chunk per step, so the split points reach the proxy intact. Record and + replay both produce one of these, driven by different step sources, which is + what makes their framing identical by construction rather than by inspection.""" + + status_code: int + headers: dict[str, str] + steps: Generator[StreamStep, None, None] + + +type EdgeOutcome = EdgeReply | EdgeStream + + def _text_reply(status_code: int, message: str) -> EdgeReply: return EdgeReply( status_code=status_code, @@ -492,34 +530,87 @@ def _text_reply(status_code: int, message: str) -> EdgeReply: ) -def _reply_from_recorded(response: RecordedHttpResponse) -> EdgeReply: - return EdgeReply( - status_code=response.status_code, - headers=dict(response.headers), - body=base64.b64decode(response.body_b64), +def _recorded_steps( + chunks_b64: Sequence[str], truncated: str | None +) -> Generator[StreamStep, None, None]: + """Replay's step source: the recorded chunks in recorded order, as fast as the + socket takes them (inter-chunk delays are deliberately not reproduced), then the + recorded truncation if the stream ended without a terminator.""" + for chunk in chunks_b64: + yield StreamChunk(data=base64.b64decode(chunk)) + if truncated is not None: + yield StreamTruncation(reason=truncated) + + +def _recorded_outcome(response: RecordedResponse) -> EdgeOutcome: + match response: + case RecordedHttpResponse(status_code=status_code, headers=headers, body_b64=body_b64): + return EdgeReply( + status_code=status_code, + headers=dict(headers), + body=base64.b64decode(body_b64), + ) + case RecordedStreamedResponse( + status_code=status_code, headers=headers, chunks_b64=chunks_b64, truncated=truncated + ): + return EdgeStream( + status_code=status_code, + headers=dict(headers), + steps=_recorded_steps(chunks_b64, truncated), + ) + case _: + assert_never(response) + + +def _filtered_response_headers(headers: Mapping[str, str]) -> dict[str, str]: + """What the edge stores and serves: the provider's headers minus hop-by-hop and + volatile entries. Framing headers are in that set, so a stored header can never + contradict the framing the edge chooses when it serves the response.""" + return { + name: value for name, value in headers.items() if name not in _RESPONSE_DROPPED_HEADERS + } + + +def _network_error_response(message: str) -> RecordedHttpResponse: + return RecordedHttpResponse( + status_code=502, + headers={"content-type": "text/plain; charset=utf-8"}, + body_b64=base64.b64encode( + f"provider edge could not reach the provider: {message}".encode() + ).decode("ascii"), ) -def _recorded_response(outcome: RawResponse | NetworkError) -> RecordedHttpResponse: - match outcome: - case RawResponse(status_code=status_code, headers=headers, body=body): - return RecordedHttpResponse( - status_code=status_code, - headers={ - name: value - for name, value in headers.items() - if name not in _RESPONSE_DROPPED_HEADERS - }, - body_b64=base64.b64encode(body).decode("ascii"), - ) - case NetworkError(message=message): - return RecordedHttpResponse( - status_code=502, - headers={"content-type": "text/plain; charset=utf-8"}, - body_b64=base64.b64encode( - f"provider edge could not reach the provider: {message}".encode() - ).decode("ascii"), - ) +def _buffered_response( + status_code: int, headers: Mapping[str, str], body: bytes +) -> RecordedHttpResponse: + return RecordedHttpResponse( + status_code=status_code, + headers=_filtered_response_headers(headers), + body_b64=base64.b64encode(body).decode("ascii"), + ) + + +def _streamed_response( + status_code: int, headers: Mapping[str, str], chunks: Sequence[bytes], truncated: str | None +) -> RecordedStreamedResponse: + return RecordedStreamedResponse( + status_code=status_code, + headers=_filtered_response_headers(headers), + chunks_b64=[base64.b64encode(chunk).decode("ascii") for chunk in chunks], + truncated=truncated, + ) + + +def _is_streamed(headers: Mapping[str, str]) -> bool: + """Whether a response is one to relay incrementally, decided by content type. + + ``transfer-encoding: chunked`` would be the wrong signal: chunking is a + transport choice providers make freely for ordinary JSON, so keying off it would + move nearly every recording to the streamed shape for no gain. The content type + is the header that says "consume this as it arrives", and it is already how the + harness defines streaming everywhere else.""" + return "text/event-stream" in _header_value(headers, "content-type").lower() def _upstream_url(upstream_base: str, upstream_path: str, query: str) -> str: @@ -527,6 +618,70 @@ def _upstream_url(upstream_base: str, upstream_path: str, query: str) -> str: return f"{url}?{query}" if query else url +def _persist( + backend: RecordEdge, test_key: str, request: RecordedRequest, response: RecordedResponse +) -> None: + with backend.lock: + backend.recorder.record(test_key=test_key, request=request, response=response) + + +def _recording_steps( + backend: RecordEdge, test_key: str, request: RecordedRequest, head: StreamHead +) -> Generator[StreamStep, None, None]: + """Record mode's step source: hand each upstream chunk downstream as it arrives + while collecting it, then persist the whole sequence once, under the lock. + Relaying incrementally keeps record exercising the proxy's incremental parser + the way a live run does. + + The ``finally`` also covers the proxy hanging up mid-stream, which closes this + generator: what arrived is still recorded, marked truncated, because recording a + cut-short stream as a clean one would let a later replay serve a well-terminated + fraction of the response and pass a test that should have gone red.""" + collected: list[bytes] = [] + truncated: str | None = None + delivered = False + try: + with closing(head.steps) as steps: + for step in steps: + match step: + case StreamChunk(data=data): + collected.append(data) + case StreamTruncation(reason=reason): + truncated = f"upstream: {reason}" + case _: + assert_never(step) + yield step + delivered = True + finally: + if not delivered and truncated is None: + truncated = f"downstream: relay closed after {len(collected)} chunks" + _persist( + backend, + test_key, + request, + _streamed_response(head.status_code, head.headers, collected, truncated), + ) + + +def _drain_to_response(head: StreamHead) -> RecordedHttpResponse: + """A response the detection rule did not call streamed: drain the same step + iterator, join the pieces, and store today's buffered shape byte for byte. A + truncation part way through degrades to the synthetic 502 exactly as the eager + read did, because storing half a JSON body under a content-length as though it + were whole would be a worse lie than failing.""" + pieces: list[bytes] = [] + with closing(head.steps) as steps: + for step in steps: + match step: + case StreamChunk(data=data): + pieces.append(data) + case StreamTruncation(reason=reason): + return _network_error_response(reason) + case _: + assert_never(step) + return _buffered_response(head.status_code, head.headers, b"".join(pieces)) + + def _handle_record( backend: RecordEdge, request: RecordedRequest, @@ -536,23 +691,37 @@ def _handle_record( headers: Mapping[str, str], body: bytes | None, timeout: float, -) -> EdgeReply: +) -> EdgeOutcome: + test_key: Final = current_test_key() forwarded: Final = { name: value for name, value in headers.items() if name.lower() not in _REQUEST_DROPPED_HEADERS } - outcome: Final = forward(method, url, headers=forwarded, body=body, timeout=timeout) - response: Final = _recorded_response(outcome) - with backend.lock: - backend.recorder.record(test_key=current_test_key(), request=request, response=response) - return _reply_from_recorded(response) + head: Final = forward_stream(method, url, headers=forwarded, body=body, timeout=timeout) + match head: + case NetworkError(message=message): + unreachable: Final = _network_error_response(message) + _persist(backend, test_key, request, unreachable) + return _recorded_outcome(unreachable) + case StreamHead() if _is_streamed(head.headers): + return EdgeStream( + status_code=head.status_code, + headers=_filtered_response_headers(head.headers), + steps=_recording_steps(backend, test_key, request, head), + ) + case StreamHead(): + buffered: Final = _drain_to_response(head) + _persist(backend, test_key, request, buffered) + return _recorded_outcome(buffered) + case _: + assert_never(head) -def _handle_replay(source: ReplaySource, request: RecordedRequest) -> EdgeReply: +def _handle_replay(source: ReplaySource, request: RecordedRequest) -> EdgeOutcome: try: interaction: Final = source.next_interaction(request) except ReplayMiss as miss: return _text_reply(REPLAY_MISS_STATUS, str(miss)) - return _reply_from_recorded(interaction.response) + return _recorded_outcome(interaction.response) def handle_edge_request( @@ -564,7 +733,7 @@ def handle_edge_request( body: bytes | None, *, timeout: float, -) -> EdgeReply: +) -> EdgeOutcome: """The edge's pure core, one HTTP exchange in and out: resolve the mount prefix, then record (forward + persist) or replay (serve from the bundle). Socket-free so unit tests exercise every branch without a server.""" @@ -618,7 +787,7 @@ class _EdgeHandler(BaseHTTPRequestHandler): assert isinstance(edge_server, _EdgeHTTPServer) length: Final = int(self.headers.get("content-length") or "0") body: Final = self.rfile.read(length) if length else None - reply: Final = handle_edge_request( + outcome: Final = handle_edge_request( edge_server.backend, edge_server.mounts, self.command, @@ -627,6 +796,15 @@ class _EdgeHandler(BaseHTTPRequestHandler): body, timeout=edge_server.forward_timeout, ) + match outcome: + case EdgeReply(): + self._write_reply(outcome) + case EdgeStream(): + self._write_stream(outcome) + case _: + assert_never(outcome) + + def _write_reply(self, reply: EdgeReply) -> None: self.send_response(reply.status_code) for name, value in reply.headers.items(): self.send_header(name, value) @@ -634,6 +812,32 @@ class _EdgeHandler(BaseHTTPRequestHandler): self.end_headers() self.wfile.write(reply.body) + def _write_stream(self, stream: EdgeStream) -> None: + """Write a streamed outcome in chunked framing, one transfer chunk per step. + + ``wbufsize`` is 0 on BaseHTTPRequestHandler, so ``wfile`` sends each write + straight down the socket and no flush is needed. A truncation step ends the + message without its terminator and closes the connection, which the stdlib + shuts down write-side first: the proxy sees a graceful close mid-message, + which is the incomplete chunked read a provider hanging up produces, and not + the reset that could discard the chunks already in flight.""" + self.send_response(stream.status_code) + for name, value in stream.headers.items(): + self.send_header(name, value) + self.send_header("transfer-encoding", "chunked") + self.end_headers() + with closing(stream.steps) as steps: + for step in steps: + match step: + case StreamChunk(data=data): + self.wfile.write(b"%x\r\n%s\r\n" % (len(data), data)) + case StreamTruncation(): + self.close_connection = True + return + case _: + assert_never(step) + self.wfile.write(b"0\r\n\r\n") + def log_message(self, format: str, *args: object) -> None: """Silence the per-request stderr line BaseHTTPRequestHandler emits.""" diff --git a/tests/e2e/test_fixture_bundle.py b/tests/e2e/test_fixture_bundle.py index b49ab565e39..c01d0b34fb5 100644 --- a/tests/e2e/test_fixture_bundle.py +++ b/tests/e2e/test_fixture_bundle.py @@ -22,6 +22,7 @@ from fixture_bundle import ( Manifest, RecordedHttpResponse, RecordedRequest, + RecordedStreamedResponse, StaleBundle, UnreadableBundle, UnsafeBundleDir, @@ -186,3 +187,45 @@ class TestRecordAndLoad: slug_for_test("suite/test_a.py::test_one"), slug_for_test("suite/test_b.py::test_two"), } + + def test_a_streamed_response_round_trips_through_the_bundle(self, tmp_path: Path) -> None: + """LIT-5742: the two response shapes share one file format and are told apart + by their ``kind`` tag, so a streamed recording comes back with its chunk list + intact rather than as a buffered response with an empty body.""" + root = tmp_path / "bundle" + recorder = prepared(root) + key = "suite/test_mod.py::test_streamed" + recorder.record( + test_key=key, + request=plain_request("/messages"), + response=RecordedStreamedResponse( + status_code=200, + headers={"content-type": "text/event-stream"}, + chunks_b64=["Zmly", "c3Q="], + truncated="upstream: hung up", + ), + ) + loaded = load_bundle(root) + assert isinstance(loaded, LoadedBundle) + (interaction,) = loaded.interactions[slug_for_test(key)] + response = interaction.response + assert isinstance(response, RecordedStreamedResponse) + assert response.chunks_b64 == ["Zmly", "c3Q="] + assert response.truncated == "upstream: hung up" + + def test_load_bundle_rejects_a_foreign_format_version(self, tmp_path: Path) -> None: + """A bundle is written atomically, so a manifest from another format version + means every response inside it may have a shape this code cannot read. Loading + has to refuse it by name, the way the freshness gate does, rather than parse + what it happens to understand.""" + root = tmp_path / "bundle" + prepared(root).record( + test_key="suite/test_mod.py::test_old", + request=plain_request("/chat"), + response=plain_response(), + ) + write_manifest(root, NOW, format_version=BUNDLE_FORMAT_VERSION - 1) + loaded = load_bundle(root) + assert isinstance(loaded, UnreadableBundle) + assert f"format_version {BUNDLE_FORMAT_VERSION - 1}" in loaded.reason + assert "E2E_FIXTURE_MODE=record" in loaded.reason diff --git a/tests/e2e/test_provider_edge.py b/tests/e2e/test_provider_edge.py index 14a9fd53393..6fe6d3cac0b 100644 --- a/tests/e2e/test_provider_edge.py +++ b/tests/e2e/test_provider_edge.py @@ -11,12 +11,20 @@ computed and closest recorded canonical keys (LIT-5741; the pure canonicalizer is pinned in test_fixture_canonical.py). Requests are made through ``e2e_http.forward`` so the whole HTTP surface of the edge is exercised; the pure ``handle_edge_request`` core is pinned socket-free alongside. + +Streaming fidelity (LIT-5742) is pinned at the transfer layer, because that is +the only layer where it is visible: a chunked provider sends a known list of +transfer chunks, one of which deliberately splits an SSE event mid-token, and a +raw-socket client reads the edge's own reply back as HTTP chunks. Counting SSE +events at the client would prove nothing, since a coalesced body carries the +same events as a chunk-per-event one. """ from __future__ import annotations import base64 import json +import socket import threading from collections.abc import Generator, Mapping from concurrent.futures import ThreadPoolExecutor @@ -36,6 +44,7 @@ from fixture_bundle import ( LoadedBundle, RecordedHttpResponse, RecordedRequest, + RecordedStreamedResponse, load_bundle, prepare_bundle, slug_for_test, @@ -44,6 +53,7 @@ from fixture_mode import current_test_key from provider_edge import ( REPLAY_MISS_STATUS, EdgeBackend, + EdgeReply, ProviderEdge, RecordEdge, ReplayEdge, @@ -116,10 +126,175 @@ def fake_provider() -> Generator[_FakeProvider]: server.server_close() -def provider_url(server: _FakeProvider) -> str: +def provider_url(server: ThreadingHTTPServer) -> str: return f"http://127.0.0.1:{server.server_address[1]}" +STREAM_PATH = "/openai/v1/messages" +STREAM_BODY = json.dumps({"model": "claude", "stream": True}).encode() +MID_EVENT_HEAD = b'data: {"type":"content_bl' +MID_EVENT_TAIL = b'ock_delta","delta":{"text":" two"}}\n\n' +SSE_CHUNKS: tuple[bytes, ...] = ( + b'data: {"type":"content_block_delta","delta":{"text":"one"}}\n\n', + MID_EVENT_HEAD, + MID_EVENT_TAIL, + b'data: {"type":"message_delta","usage":{"output_tokens":7}}\n\n', + b"data: [DONE]\n\n", +) +JSON_CHUNKS: tuple[bytes, ...] = (b'{"echo":"one",', b'"chunked":true}') + + +class _ChunkedProvider(ThreadingHTTPServer): + """A provider that frames its response as a known list of transfer chunks, each + flushed on its own, and optionally hangs up part way through without writing the + terminating chunk. The chunk list is what the recording has to reproduce.""" + + daemon_threads = True + + def __init__( + self, + bind: tuple[str, int], + *, + chunks: tuple[bytes, ...], + content_type: str, + abort_after: int | None, + ) -> None: + super().__init__(bind, _ChunkedProviderHandler) + self.chunks = chunks + self.content_type = content_type + self.abort_after = abort_after + self.hits: list[str] = [] + + +class _ChunkedProviderHandler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def do_POST(self) -> None: + provider = self.server + assert isinstance(provider, _ChunkedProvider) + length = int(self.headers.get("content-length") or "0") + if length: + self.rfile.read(length) + provider.hits.append(f"{self.command} {self.path}") + self.send_response(200) + self.send_header("content-type", provider.content_type) + self.send_header("transfer-encoding", "chunked") + self.end_headers() + limit = len(provider.chunks) if provider.abort_after is None else provider.abort_after + for chunk in provider.chunks[:limit]: + self.wfile.write(b"%x\r\n%s\r\n" % (len(chunk), chunk)) + self.wfile.flush() + if limit < len(provider.chunks): + self.close_connection = True + return + self.wfile.write(b"0\r\n\r\n") + self.wfile.flush() + + def log_message(self, format: str, *args: object) -> None: + """Silence the per-request stderr line BaseHTTPRequestHandler emits.""" + + +@contextmanager +def chunked_provider( + *, + chunks: tuple[bytes, ...] = SSE_CHUNKS, + content_type: str = "text/event-stream", + abort_after: int | None = None, +) -> Generator[_ChunkedProvider]: + server = _ChunkedProvider( + ("127.0.0.1", 0), chunks=chunks, content_type=content_type, abort_after=abort_after + ) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield server + finally: + server.shutdown() + server.server_close() + + +def response_header(head: str, name: str) -> str | None: + wanted = f"{name.lower()}:" + for line in head.splitlines()[1:]: + if line.lower().startswith(wanted): + return line.split(":", 1)[1].strip() + return None + + +def _read_chunked(sock: socket.socket, buffered: bytes) -> tuple[list[bytes], str]: + """A chunked body read back one entry per HTTP chunk, plus how the message ended. + + The framing is parsed rather than ``recv`` calls counted, because TCP is free to + coalesce two chunks into one segment or split one across two, so a read count + says nothing about how the sender framed the message.""" + chunks: list[bytes] = [] + try: + while True: + while b"\r\n" not in buffered: + piece = sock.recv(65536) + if not piece: + return chunks, "truncated" + buffered += piece + line, _, buffered = buffered.partition(b"\r\n") + size = int(line.split(b";")[0], 16) + if size == 0: + return chunks, "terminated" + while len(buffered) < size + 2: + piece = sock.recv(65536) + if not piece: + return chunks, "truncated" + buffered += piece + chunks.append(buffered[:size]) + buffered = buffered[size + 2 :] + except ConnectionResetError: + return chunks, "reset" + + +def _read_fixed(sock: socket.socket, buffered: bytes, length: int) -> tuple[list[bytes], str]: + while len(buffered) < length: + piece = sock.recv(65536) + if not piece: + return ([buffered] if buffered else []), "truncated" + buffered += piece + return ([buffered[:length]] if length else []), "terminated" + + +def raw_stream_post(port: int, path: str, body: bytes) -> tuple[str, list[bytes], str]: + """POST over a raw socket and read the reply at the transfer layer: the response + head, one entry per HTTP chunk (or the whole body for a content-length reply), + and how the message ended, ``terminated`` when its terminator arrived, + ``truncated`` on a graceful close before it, ``reset`` on an abortive one. + + ``call_edge`` goes through ``forward``, which buffers, so it cannot see any of + this; the streaming tests need the framing itself, so they read the socket.""" + sock = socket.create_connection(("127.0.0.1", port), timeout=15) + try: + sock.sendall( + ( + f"POST {path} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\n" + f"content-type: application/json\r\ncontent-length: {len(body)}\r\n\r\n" + ).encode() + + body + ) + buffered = b"" + while b"\r\n\r\n" not in buffered: + piece = sock.recv(65536) + if not piece: + break + buffered += piece + head_bytes, _, rest = buffered.partition(b"\r\n\r\n") + head = head_bytes.decode("latin-1") + if (response_header(head, "transfer-encoding") or "").lower() == "chunked": + chunks, ending = _read_chunked(sock, rest) + else: + chunks, ending = _read_fixed( + sock, rest, int(response_header(head, "content-length") or 0) + ) + return head, chunks, ending + finally: + sock.close() + + @contextmanager def running_edge(backend: EdgeBackend, mounts: Mapping[str, str]) -> Generator[ProviderEdge]: running = start_provider_edge(backend, mounts=mounts, bind_host="127.0.0.1") @@ -787,6 +962,207 @@ class TestConcurrentReplay: assert source.leftover_error(current_test_key()) is None +def record_stream(root: Path, *, abort_after: int | None = None) -> tuple[str, list[bytes], str]: + with chunked_provider(abort_after=abort_after) as provider: + with running_edge(record_backend(root), {"openai": provider_url(provider)}) as edge: + return raw_stream_post(edge.port, STREAM_PATH, STREAM_BODY) + + +def replay_stream(root: Path) -> tuple[str, list[bytes], str]: + with running_edge(ReplayEdge(source=replay_source(root)), REPLAY_MOUNTS) as edge: + return raw_stream_post(edge.port, STREAM_PATH, STREAM_BODY) + + +def only_recorded_response(root: Path) -> RecordedHttpResponse | RecordedStreamedResponse: + files = this_tests_files(root) + assert len(files) == 1, [file.name for file in files] + return Interaction.model_validate_json(files[0].read_text(encoding="utf-8")).response + + +def recorded_stream(root: Path) -> RecordedStreamedResponse: + response = only_recorded_response(root) + assert isinstance(response, RecordedStreamedResponse), response + return response + + +def stream_chunks(response: RecordedStreamedResponse) -> list[bytes]: + return [base64.b64decode(chunk) for chunk in response.chunks_b64] + + +class TestStreamingFidelity: + """LIT-5742: a streamed response records and replays as the chunk sequence the + provider actually sent, not as one coalesced body. The unit of fidelity is the + HTTP transfer chunk, so every assertion here is made at the transfer layer.""" + + def test_a_streamed_response_records_its_chunk_boundaries(self, tmp_path: Path) -> None: + root = tmp_path / "bundle" + record_stream(root) + + recorded = recorded_stream(root) + assert recorded.status_code == 200 + assert stream_chunks(recorded) == list(SSE_CHUNKS) + assert recorded.truncated is None + + def test_replay_reproduces_the_recorded_split_points(self, tmp_path: Path) -> None: + root = tmp_path / "bundle" + record_stream(root) + + head, chunks, ending = replay_stream(root) + assert head.startswith("HTTP/1.1 200 OK") + assert response_header(head, "transfer-encoding") == "chunked" + assert response_header(head, "content-type") == "text/event-stream" + assert len(chunks) > 1 + assert chunks == list(SSE_CHUNKS) + assert ending == "terminated" + + def test_record_mode_relays_the_stream_chunked_like_replay_will(self, tmp_path: Path) -> None: + """Record/replay parity at the framing level: what record serves the proxy + must be what replay serves it later, chunk for chunk.""" + root = tmp_path / "bundle" + recorded_head, recorded_chunks, recorded_ending = record_stream(root) + replayed_head, replayed_chunks, replayed_ending = replay_stream(root) + + assert response_header(recorded_head, "transfer-encoding") == "chunked" + assert recorded_chunks == list(SSE_CHUNKS) + assert recorded_chunks == replayed_chunks + assert recorded_ending == replayed_ending == "terminated" + assert response_header(recorded_head, "transfer-encoding") == response_header( + replayed_head, "transfer-encoding" + ) + + def test_a_chunk_split_inside_an_event_survives_replay(self, tmp_path: Path) -> None: + """The anti-tautology test. One provider chunk ends mid-token, so the two + halves of that SSE event must arrive as two chunks; an implementation that + joins the body and re-splits it on event boundaries cannot pass this.""" + root = tmp_path / "bundle" + record_stream(root) + + _, chunks, _ = replay_stream(root) + split_at = SSE_CHUNKS.index(MID_EVENT_HEAD) + assert chunks[split_at] == MID_EVENT_HEAD + assert chunks[split_at + 1] == MID_EVENT_TAIL + assert b"content_block_delta" not in chunks[split_at] + assert b"content_block_delta" in chunks[split_at] + chunks[split_at + 1] + + def test_the_usage_chunk_replays_in_its_recorded_position(self, tmp_path: Path) -> None: + root = tmp_path / "bundle" + record_stream(root) + recorded = stream_chunks(recorded_stream(root)) + + _, replayed, _ = replay_stream(root) + usage_positions = [ + index for index, chunk in enumerate(recorded) if b"output_tokens" in chunk + ] + assert usage_positions == [ + index for index, chunk in enumerate(replayed) if b"output_tokens" in chunk + ] + assert usage_positions == [len(replayed) - 2] + assert replayed[-1] == SSE_CHUNKS[-1] + + def test_a_mid_stream_upstream_failure_records_the_delivered_chunks_and_the_truncation( + self, tmp_path: Path + ) -> None: + """The provider delivers two chunks and hangs up. The deltas it did send are + the difference between a stream that died and a request that never streamed, + so they are recorded, and the recording says the stream never terminated.""" + root = tmp_path / "bundle" + head, chunks, ending = record_stream(root, abort_after=2) + + assert head.startswith("HTTP/1.1 200 OK") + assert chunks == list(SSE_CHUNKS[:2]) + assert ending == "truncated" + recorded = recorded_stream(root) + assert recorded.status_code == 200 + assert stream_chunks(recorded) == list(SSE_CHUNKS[:2]) + assert recorded.truncated is not None + assert recorded.truncated.startswith("upstream: ") + + def test_a_truncated_recording_replays_as_a_truncated_stream(self, tmp_path: Path) -> None: + root = tmp_path / "bundle" + record_stream(root, abort_after=2) + + head, chunks, ending = replay_stream(root) + assert head.startswith("HTTP/1.1 200 OK") + assert response_header(head, "transfer-encoding") == "chunked" + assert chunks == list(SSE_CHUNKS[:2]) + assert ending == "truncated" + + def test_a_non_streamed_response_keeps_the_buffered_shape(self, tmp_path: Path) -> None: + """No-churn guard: an ordinary JSON response records and is framed exactly as + it was before streaming existed.""" + root = tmp_path / "bundle" + with fake_provider() as provider: + with running_edge(record_backend(root), {"openai": provider_url(provider)}) as edge: + head, chunks, ending = raw_stream_post(edge.port, CHAT_PATH, chat_body("hi")) + + response = only_recorded_response(root) + assert isinstance(response, RecordedHttpResponse) + assert response_header(head, "transfer-encoding") is None + assert response_header(head, "content-length") is not None + assert ending == "terminated" + assert json_object(b"".join(chunks))["echo"] == chat_body("hi").decode() + + def test_a_chunked_non_sse_response_stays_buffered(self, tmp_path: Path) -> None: + """Detection keys off the content type, not the transfer encoding: providers + chunk ordinary JSON freely, and treating that as streamed would move nearly + every recording to the chunk-list shape for no gain.""" + root = tmp_path / "bundle" + with chunked_provider(chunks=JSON_CHUNKS, content_type="application/json") as provider: + with running_edge(record_backend(root), {"openai": provider_url(provider)}) as edge: + head, chunks, _ = raw_stream_post(edge.port, CHAT_PATH, chat_body("hi")) + + response = only_recorded_response(root) + assert isinstance(response, RecordedHttpResponse) + assert base64.b64decode(response.body_b64) == b"".join(JSON_CHUNKS) + assert response_header(head, "transfer-encoding") is None + assert b"".join(chunks) == b"".join(JSON_CHUNKS) + + def test_replay_of_a_stream_makes_no_provider_connection(self, tmp_path: Path) -> None: + root = tmp_path / "bundle" + with chunked_provider() as provider: + with running_edge(record_backend(root), {"openai": provider_url(provider)}) as edge: + raw_stream_post(edge.port, STREAM_PATH, STREAM_BODY) + hits_after_record = list(provider.hits) + with running_edge( + ReplayEdge(source=replay_source(root)), {"openai": provider_url(provider)} + ) as edge: + _, chunks, ending = raw_stream_post(edge.port, STREAM_PATH, STREAM_BODY) + assert provider.hits == hits_after_record == ["POST /v1/messages"] + assert chunks == list(SSE_CHUNKS) + assert ending == "terminated" + + def test_concurrent_streams_each_record_their_own_chunks(self, tmp_path: Path) -> None: + """The edge relays streams on concurrent threads and each one takes the + recorder lock once, at the end, so neither recording loses or borrows a chunk + from the other.""" + root = tmp_path / "bundle" + bodies = [ + json.dumps({"model": "claude", "stream": True, "n": index}).encode() + for index in range(2) + ] + barrier = threading.Barrier(len(bodies)) + with chunked_provider() as provider: + with running_edge(record_backend(root), {"openai": provider_url(provider)}) as edge: + + def consume(body: bytes) -> tuple[list[bytes], str]: + barrier.wait() + _, chunks, ending = raw_stream_post(edge.port, STREAM_PATH, body) + return chunks, ending + + with ThreadPoolExecutor(max_workers=len(bodies)) as executor: + served = list(executor.map(consume, bodies)) + + assert served == [(list(SSE_CHUNKS), "terminated")] * len(bodies) + files = this_tests_files(root) + assert len(files) == len(bodies) + for file in files: + response = Interaction.model_validate_json( + file.read_text(encoding="utf-8") + ).response + assert isinstance(response, RecordedStreamedResponse), response + assert stream_chunks(response) == list(SSE_CHUNKS) + + class TestHandleEdgeRequestPure: def test_unknown_mount_404s_naming_the_known_mounts(self, tmp_path: Path) -> None: root = tmp_path / "bundle" @@ -800,6 +1176,7 @@ class TestHandleEdgeRequestPure: b"{}", timeout=1.0, ) + assert isinstance(reply, EdgeReply) assert reply.status_code == 404 assert b"unknown provider mount 'bedrock'" in reply.body assert b"anthropic, openai" in reply.body @@ -824,6 +1201,7 @@ class TestHandleEdgeRequestPure: json.dumps({"prompt": "x"}).encode(), timeout=1.0, ) + assert isinstance(reply, EdgeReply) assert reply.status_code == 201 assert reply.body == b"ok" assert reply.headers == {"x-upstream": "fake"} From 721227e9eee3b5650aaf0702133847d79d1497d6 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Mon, 24 Aug 2026 12:57:32 -0700 Subject: [PATCH 10/20] refactor(router): scrub retry-breadcrumb credentials by pattern, not a denylist Enumerating credential-bearing kwargs in RETRY_BREADCRUMB_EXCLUDED_KWARGS is always one new kwarg behind: it missed top-level extra_headers and provider token fields, which log_retry still copied into router.previous_models verbatim. Scrub the breadcrumb with mask_credentials_in_payload instead, so credential-named values are masked at any depth (extra_headers.authorization, api_key, aws_secret_access_key, vertex_credentials, azure_ad_token, and future kwargs), and leave the exclusion set to the request payload and router walk state only. This hardens the in-memory breadcrumb; it is not a fix for a reproduced SpendLogs leak. The SpendLogs metadata allowlist and the universal previous_models stripping already keep this breadcrumb off every persisted surface. Parametrize the regression test over provider_specific_header, extra_headers, and api_key, asserting the raw credential value never survives into previous_models for any shape while the container key still reaches the breadcrumb --- litellm/router.py | 15 +++++---- tests/test_litellm/test_router.py | 51 +++++++++++++++++++++---------- 2 files changed, 42 insertions(+), 24 deletions(-) diff --git a/litellm/router.py b/litellm/router.py index 132ff6671a7..6ee474730c9 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -80,6 +80,7 @@ from litellm.litellm_core_utils.request_timeout_resolver import ( from litellm.litellm_core_utils.secret_redaction import redact_string from litellm.litellm_core_utils.sensitive_data_masker import ( SensitiveDataMasker, + mask_credentials_in_payload, mask_sensitive_structure, ) from litellm.llms.openai_like.json_loader import JSONProviderRegistry @@ -375,18 +376,15 @@ def _replay_live_router_model_cost() -> None: set_live_deployment_replay(_replay_live_router_model_cost) -# Kwargs that log_retry must not copy into a retry breadcrumb. The breadcrumbs reach spend -# logs and logging callbacks, so they must never carry the request payload, router-internal -# walk state, or transport credentials: provider_specific_header / headers / api_key can hold a -# client's forwarded Authorization or a provider key, none of which identify the failed attempt. +# Kwargs that carry no signal about the failed attempt, so log_retry drops them from a +# breadcrumb entirely: the request payload and the router-internal walk state. Credentials are +# handled separately by mask_credentials_in_payload, which scrubs credential-named values from +# whatever kwargs remain rather than trying to enumerate every credential-bearing key here. RETRY_BREADCRUMB_EXCLUDED_KWARGS: Final = frozenset( ( "messages", "original_function", "attempted_targets", - "provider_specific_header", - "headers", - "api_key", ) ) @@ -7357,7 +7355,8 @@ class Router: if len(self.previous_models) > 3: self.previous_models.pop(0) - self.previous_models.append(previous_model) + scrubbed_previous_model: Final = mask_credentials_in_payload(previous_model) + self.previous_models.append(scrubbed_previous_model) kwargs[_metadata_var]["previous_models"] = self.previous_models return kwargs except Exception as e: diff --git a/tests/test_litellm/test_router.py b/tests/test_litellm/test_router.py index 7e6cc010834..910b874c2ac 100644 --- a/tests/test_litellm/test_router.py +++ b/tests/test_litellm/test_router.py @@ -8565,28 +8565,47 @@ async def test_retry_breadcrumbs_do_not_carry_the_walk_state(): assert "attempted_targets" not in breadcrumb +_BREADCRUMB_CREDENTIAL_CANARY = "Bearer sk-ant-oat01-RETRY-BREADCRUMB-CANARY-doNotShip" + + +@pytest.mark.parametrize( + "container_key, request_kwargs", + [ + ( + "provider_specific_header", + { + "provider_specific_header": { + "custom_llm_provider": "openai", + "extra_headers": {"authorization": _BREADCRUMB_CREDENTIAL_CANARY}, + } + }, + ), + ( + "extra_headers", + {"extra_headers": {"authorization": _BREADCRUMB_CREDENTIAL_CANARY}}, + ), + ( + "api_key", + {"api_key": _BREADCRUMB_CREDENTIAL_CANARY}, + ), + ], +) @pytest.mark.asyncio -async def test_retry_breadcrumbs_drop_forwarded_client_credentials(): - """log_retry copies kwargs verbatim into previous_models, which reaches spend logs and logging - callbacks. provider_specific_header can carry a client's forwarded Authorization token, and a - breadcrumb has no diagnostic use for it, so the raw credential must never land in the breadcrumb.""" - canary = "Bearer sk-ant-oat01-RETRY-BREADCRUMB-CANARY-doNotShip" +async def test_retry_breadcrumbs_never_carry_a_forwarded_credential(container_key, request_kwargs): + """log_retry copies kwargs into previous_models, which reaches spend logs and logging callbacks. + Any of these kwargs can carry a client's forwarded Authorization token or a provider key, and a + breadcrumb has no diagnostic use for the raw secret. A denylist of key names is always one new + credential kwarg behind, so log_retry scrubs credential-named values by pattern instead: the + container still reaches the breadcrumb, but the raw secret never does, whatever key holds it.""" router = _cyclic_fallback_router(num_retries=1) capture = _LogCapture(logging.ERROR) - await _drive_cyclic_fallback( - router, - capture, - provider_specific_header={ - "custom_llm_provider": "openai", - "extra_headers": {"authorization": canary}, - }, - ) + await _drive_cyclic_fallback(router, capture, **request_kwargs) assert router.previous_models, "no retry breadcrumbs were recorded" - for breadcrumb in router.previous_models: - assert "provider_specific_header" not in breadcrumb - assert canary not in json.dumps(router.previous_models, default=str) + dumped = json.dumps(router.previous_models, default=str) + assert container_key in dumped, "the credential-bearing kwarg never reached the breadcrumb, so this test cannot see the leak" + assert _BREADCRUMB_CREDENTIAL_CANARY not in dumped @pytest.mark.asyncio From f3dc339e074f47159d892fea47b53704f8f782a0 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Mon, 24 Aug 2026 13:06:13 -0700 Subject: [PATCH 11/20] fix(vertex-passthrough): value-strip the key by full auth precedence The credential-less filter derived the caller key only from x-litellm-api-key, Authorization, and the custom header, but the route authenticates through Depends(user_api_key_auth), which also accepts the key from x-goog-api-key. A virtual key sent only in x-goog-api-key therefore authenticated yet was kept as a preserved upstream header and forwarded to Google. Resolve the caller key by the same precedence get_api_key uses and value-strip exactly that, so a key in x-goog-api-key is stripped while a real Google key alongside a higher-precedence virtual key is preserved. --- .../llm_passthrough_endpoints.py | 60 +++++++++++++------ .../test_llm_pass_through_endpoints.py | 25 ++++++-- 2 files changed, 62 insertions(+), 23 deletions(-) diff --git a/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py b/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py index bcdc9b0a68f..d86b62e6fb1 100644 --- a/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py +++ b/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py @@ -1748,23 +1748,42 @@ _HEADERS_NEVER_FORWARDED_TO_VERTEX: Final = frozenset({"content-length", "host"} ) -def _credentialless_caller_key_values(request: Request) -> frozenset[str]: - """Every header value the proxy would accept as this caller's LiteLLM key. +_VERTEX_CALLER_KEY_HEADER_PRECEDENCE: Final = ( + SpecialHeaders.custom_litellm_api_key.value.lower(), + SpecialHeaders.openai_authorization.value.lower(), + SpecialHeaders.azure_authorization.value.lower(), + SpecialHeaders.anthropic_authorization.value.lower(), + SpecialHeaders.google_ai_studio_authorization.value.lower(), + SpecialHeaders.azure_apim_authorization.value.lower(), +) - Beyond the built-in ``x-litellm-api-key`` / ``Authorization`` that - ``get_litellm_virtual_key`` reads, ``user_api_key_auth`` also authenticates a - caller from the operator-configured ``general_settings.litellm_key_header_name`` - when one is set, reading that header straight off the request. Any of those - values equals the virtual key and must never be forwarded to Google. + +def _authenticated_caller_key_values(request: Request) -> frozenset[str]: + """The value ``user_api_key_auth`` would accept as this caller's LiteLLM key. + + The Vertex route authenticates through ``Depends(user_api_key_auth)``, which + resolves the key from the first present of the credential headers in + ``get_api_key``'s precedence order, with the operator-configured + ``general_settings.litellm_key_header_name`` overriding all of them. Some of + those headers (``Authorization``, ``x-goog-api-key``) are also kept as genuine + bring-your-own Google credentials, so returning only the value that actually + authenticated lets the filter strip that value wherever it appears while + leaving a real Google credential in place. An empty set means no caller key + was found, so nothing is value-stripped. """ from litellm.proxy.proxy_server import general_settings - custom_key_header_name: Final = general_settings.get("litellm_key_header_name") or "" - candidates: Final = ( - get_litellm_virtual_key(request), - request.headers.get(custom_key_header_name, "") if custom_key_header_name else "", + incoming: Final = _safe_get_request_headers(request) + custom_key_header_name: Final = (general_settings.get("litellm_key_header_name") or "").lower() + ordered_names: Final = ( + (custom_key_header_name,) if custom_key_header_name else () + ) + _VERTEX_CALLER_KEY_HEADER_PRECEDENCE + present_values: Final = (incoming[name] for name in ordered_names if incoming.get(name)) + authenticated_key: Final = next( + (stripped for value in present_values if (stripped := _bearer_stripped(value))), + "", ) - return frozenset(_bearer_stripped(value) for value in candidates if _bearer_stripped(value)) + return frozenset({authenticated_key}) if authenticated_key else frozenset() def _forwarded_headers_for_credentialless_vertex_passthrough(request: Request) -> Mapping[str, str]: @@ -1780,15 +1799,18 @@ def _forwarded_headers_for_credentialless_vertex_passthrough(request: Request) - (everything in that set except those two, e.g. ``x-litellm-api-key`` / ``api-key`` / ``x-api-key`` / ``Ocp-Apim-Subscription-Key``) are dropped by name. ``Authorization`` and ``x-goog-api-key`` may instead carry a genuine - bring-your-own Google credential, so they are kept unless their value is one of - the caller's LiteLLM key values, which are dropped by value (normalizing any - ``Bearer`` prefix). Dropping by value also covers a virtual key sent in the - operator-configured ``litellm_key_header_name``, whatever that header is named. - When neither a surviving ``Authorization`` nor ``x-goog-api-key`` remains the - request is rejected so the virtual key cannot leak upstream. + bring-your-own Google credential, so they are kept unless their value is the + caller's authenticated LiteLLM key, which is dropped by value (normalizing any + ``Bearer`` prefix). Because the value that authenticated is resolved by the + same precedence ``user_api_key_auth`` uses, a virtual key sent only in + ``x-goog-api-key`` (or in the operator-configured ``litellm_key_header_name``) + is dropped too, while a real Google key in ``x-goog-api-key`` alongside a + virtual key in a higher-precedence header is preserved. When neither a + surviving ``Authorization`` nor ``x-goog-api-key`` remains the request is + rejected so the virtual key cannot leak upstream. """ incoming: Final = _safe_get_request_headers(request) - caller_key_values: Final = _credentialless_caller_key_values(request) + caller_key_values: Final = _authenticated_caller_key_values(request) forwarded: Final = MappingProxyType( { name: value diff --git a/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py b/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py index b65e2b2f499..9abc48ee800 100644 --- a/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py +++ b/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py @@ -3553,6 +3553,18 @@ class TestVertexCredentiallessPassthroughVirtualKeyLeak: assert forwarded is None, "the virtual key in x-goog-api-key must not satisfy the gate nor be forwarded" assert raised is not None and raised.status_code == 401 + @pytest.mark.asyncio + async def test_virtual_key_authenticated_solely_via_x_goog_api_key_is_rejected(self, monkeypatch): + raised, forwarded = await self._run( + monkeypatch, + [ + (b"x-goog-api-key", self.VKEY.encode()), + (b"content-type", b"application/json"), + ], + ) + assert forwarded is None, "a virtual key that authenticated via x-goog-api-key must be stripped, not forwarded" + assert raised is not None and raised.status_code == 401 + @pytest.mark.asyncio async def test_byo_google_oauth_token_still_forwards_without_virtual_key(self, monkeypatch): raised, forwarded = await self._run( @@ -3611,12 +3623,16 @@ class TestVertexCredentiallessPassthroughVirtualKeyLeak: @pytest.mark.asyncio @pytest.mark.parametrize( "credential_header", - sorted(SpecialHeaders.litellm_credential_header_names() - {"authorization", "x-goog-api-key"}), + sorted( + SpecialHeaders.litellm_credential_header_names() + - {"authorization", "x-goog-api-key", "x-litellm-api-key"} + ), ) async def test_every_non_google_credential_header_is_dropped_by_name(self, monkeypatch, credential_header): raised, forwarded = await self._run( monkeypatch, [ + (b"x-litellm-api-key", self.VKEY.encode()), (b"x-goog-api-key", b"AIza-real-google-api-key"), (credential_header.encode(), b"some-distinct-caller-secret-value"), (b"content-type", b"application/json"), @@ -3626,9 +3642,10 @@ class TestVertexCredentiallessPassthroughVirtualKeyLeak: assert forwarded is not None assert forwarded.get("x-goog-api-key") == "AIza-real-google-api-key" assert credential_header not in forwarded - assert "some-distinct-caller-secret-value" not in " ".join( - f"{name}:{value}" for name, value in forwarded.items() - ) + assert "x-litellm-api-key" not in forwarded + forwarded_blob = " ".join(f"{name}:{value}" for name, value in forwarded.items()) + assert self.VKEY not in forwarded_blob + assert "some-distinct-caller-secret-value" not in forwarded_blob @pytest.mark.asyncio async def test_virtual_key_in_operator_configured_header_is_stripped(self, monkeypatch): From 5d8286c963d4276cbc0bc5e25cb290b9ab463bc7 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Mon, 24 Aug 2026 13:21:56 -0700 Subject: [PATCH 12/20] fix(vertex-passthrough): normalize caller key via canonical _get_bearer_token The filter's own Bearer-only stripping missed the other schemes user_api_key_auth accepts, so a virtual key echoed as `Authorization: Basic ` alongside a higher-precedence auth header did not match the caller key and was forwarded to Google. Reuse the auth module's _get_bearer_token so the comparison strips exactly what authentication does (Bearer / bearer / Basic / AWS4-HMAC-SHA256), falling back to the raw value for a bare token. --- .../llm_passthrough_endpoints.py | 30 +++++++++++++------ .../test_llm_pass_through_endpoints.py | 14 +++++++++ 2 files changed, 35 insertions(+), 9 deletions(-) diff --git a/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py b/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py index d86b62e6fb1..4a712a6796e 100644 --- a/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py +++ b/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py @@ -29,7 +29,11 @@ from litellm.llms.anthropic.common_utils import AnthropicModelInfo from litellm.llms.vertex_ai.vertex_llm_base import VertexBase from litellm.proxy._types import * from litellm.proxy.auth.route_checks import RouteChecks -from litellm.proxy.auth.user_api_key_auth import user_api_key_auth, user_api_key_auth_websocket +from litellm.proxy.auth.user_api_key_auth import ( + _get_bearer_token, + user_api_key_auth, + user_api_key_auth_websocket, +) from litellm.proxy.common_utils.http_parsing_utils import ( _read_request_body, _safe_get_request_headers, @@ -1735,11 +1739,18 @@ _CREDENTIALLESS_VERTEX_MISSING_CREDENTIAL_DETAIL: Final = ( ) -def _bearer_stripped(value: str) -> str: - parts: Final = value.split(None, 1) - if len(parts) == 2 and parts[0].lower() == "bearer": - return parts[1] - return value +def _normalize_credential_value(value: str) -> str: + """Reduce a header value to the bare token, matching how ``user_api_key_auth`` + reads a caller's key. + + Reuses the auth module's ``_get_bearer_token`` so the caller-key comparison + strips exactly the schemes authentication accepts (``Bearer`` / ``bearer`` / + ``Basic`` / ``AWS4-HMAC-SHA256`` credential), rather than re-deriving a + narrower normalization here. ``_get_bearer_token`` returns ``""`` for a value + with no recognized scheme prefix, so a bare token (or a real Google + credential that carries no scheme) falls back to its own value. + """ + return _get_bearer_token(value) or value _VERTEX_UPSTREAM_CREDENTIAL_HEADERS: Final = frozenset({"authorization", "x-goog-api-key"}) @@ -1780,7 +1791,7 @@ def _authenticated_caller_key_values(request: Request) -> frozenset[str]: ) + _VERTEX_CALLER_KEY_HEADER_PRECEDENCE present_values: Final = (incoming[name] for name in ordered_names if incoming.get(name)) authenticated_key: Final = next( - (stripped for value in present_values if (stripped := _bearer_stripped(value))), + (stripped for value in present_values if (stripped := _normalize_credential_value(value))), "", ) return frozenset({authenticated_key}) if authenticated_key else frozenset() @@ -1801,7 +1812,7 @@ def _forwarded_headers_for_credentialless_vertex_passthrough(request: Request) - name. ``Authorization`` and ``x-goog-api-key`` may instead carry a genuine bring-your-own Google credential, so they are kept unless their value is the caller's authenticated LiteLLM key, which is dropped by value (normalizing any - ``Bearer`` prefix). Because the value that authenticated is resolved by the + ``Bearer`` / ``Basic`` / ``AWS4`` auth-scheme prefix the same way authentication does). Because the value that authenticated is resolved by the same precedence ``user_api_key_auth`` uses, a virtual key sent only in ``x-goog-api-key`` (or in the operator-configured ``litellm_key_header_name``) is dropped too, while a real Google key in ``x-goog-api-key`` alongside a @@ -1815,7 +1826,8 @@ def _forwarded_headers_for_credentialless_vertex_passthrough(request: Request) - { name: value for name, value in incoming.items() - if name not in _HEADERS_NEVER_FORWARDED_TO_VERTEX and _bearer_stripped(value) not in caller_key_values + if name not in _HEADERS_NEVER_FORWARDED_TO_VERTEX + and _normalize_credential_value(value) not in caller_key_values } ) if "authorization" not in forwarded and "x-goog-api-key" not in forwarded: diff --git a/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py b/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py index 9abc48ee800..243aadfc981 100644 --- a/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py +++ b/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py @@ -3581,6 +3581,20 @@ class TestVertexCredentiallessPassthroughVirtualKeyLeak: assert "x-litellm-api-key" not in forwarded assert self.VKEY not in " ".join(f"{name}:{value}" for name, value in forwarded.items()) + @pytest.mark.asyncio + @pytest.mark.parametrize("scheme", ["Bearer", "bearer", "Basic"]) + async def test_virtual_key_echoed_in_authorization_with_any_scheme_is_stripped(self, monkeypatch, scheme): + raised, forwarded = await self._run( + monkeypatch, + [ + (b"x-litellm-api-key", self.VKEY.encode()), + (b"authorization", f"{scheme} {self.VKEY}".encode()), + (b"content-type", b"application/json"), + ], + ) + assert forwarded is None, f"a virtual key echoed as '{scheme} ' in Authorization must be stripped, not forwarded" + assert raised is not None and raised.status_code == 401 + @pytest.mark.asyncio async def test_byo_x_goog_api_key_still_forwards_without_virtual_key(self, monkeypatch): raised, forwarded = await self._run( From b2f7216a6be1f09f4363f2c29b4cff76079733a0 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Mon, 24 Aug 2026 13:26:58 -0700 Subject: [PATCH 13/20] fix(e2e): record a streamed chunk only after its downstream write lands A downstream disconnect mid-relay was recording the chunk whose write never landed, so replay would hand back a byte the record run never delivered. Append each chunk after its yield returns, and label the truncation from the generator close, so the recording holds exactly what the proxy received. --- tests/e2e/provider_edge.py | 33 ++++++++++++++++------------ tests/e2e/test_provider_edge.py | 38 ++++++++++++++++++++++++++++++++- 2 files changed, 56 insertions(+), 15 deletions(-) diff --git a/tests/e2e/provider_edge.py b/tests/e2e/provider_edge.py index 6cedf633a3e..ceb695ffcd6 100644 --- a/tests/e2e/provider_edge.py +++ b/tests/e2e/provider_edge.py @@ -628,33 +628,38 @@ def _persist( def _recording_steps( backend: RecordEdge, test_key: str, request: RecordedRequest, head: StreamHead ) -> Generator[StreamStep, None, None]: - """Record mode's step source: hand each upstream chunk downstream as it arrives - while collecting it, then persist the whole sequence once, under the lock. - Relaying incrementally keeps record exercising the proxy's incremental parser - the way a live run does. + """Record mode's step source: hand each upstream chunk downstream and record it + only once that write has returned, then persist the whole sequence once, under + the lock. Relaying incrementally keeps record exercising the proxy's incremental + parser the way a live run does. - The ``finally`` also covers the proxy hanging up mid-stream, which closes this - generator: what arrived is still recorded, marked truncated, because recording a - cut-short stream as a clean one would let a later replay serve a well-terminated - fraction of the response and pass a test that should have gone red.""" + A chunk is appended after its ``yield`` returns, so a downstream that hangs up + mid-relay records exactly the chunks it took and never the one whose write + raised. The ``except`` covers that downstream close and the proxy hanging up + mid-stream; either way the ``finally`` persists what arrived, marked truncated, + because recording a cut-short stream as a clean one would let a later replay + serve a well-terminated fraction of the response and pass a test that should + have gone red.""" collected: list[bytes] = [] truncated: str | None = None - delivered = False try: with closing(head.steps) as steps: for step in steps: match step: - case StreamChunk(data=data): - collected.append(data) + case StreamChunk(): + pass case StreamTruncation(reason=reason): truncated = f"upstream: {reason}" case _: assert_never(step) yield step - delivered = True - finally: - if not delivered and truncated is None: + if isinstance(step, StreamChunk): + collected.append(step.data) + except GeneratorExit: + if truncated is None: truncated = f"downstream: relay closed after {len(collected)} chunks" + raise + finally: _persist( backend, test_key, diff --git a/tests/e2e/test_provider_edge.py b/tests/e2e/test_provider_edge.py index 6fe6d3cac0b..8ab389ee43c 100644 --- a/tests/e2e/test_provider_edge.py +++ b/tests/e2e/test_provider_edge.py @@ -36,7 +36,7 @@ from typing import Final import pytest from pydantic import TypeAdapter -from e2e_http import RawResponse, forward +from e2e_http import RawResponse, StreamChunk, forward from fixture_canonical import canonicalize from fixture_bundle import ( BundleRecorder, @@ -54,6 +54,7 @@ from provider_edge import ( REPLAY_MISS_STATUS, EdgeBackend, EdgeReply, + EdgeStream, ProviderEdge, RecordEdge, ReplayEdge, @@ -1077,6 +1078,41 @@ class TestStreamingFidelity: assert recorded.truncated is not None assert recorded.truncated.startswith("upstream: ") + def test_a_downstream_disconnect_mid_relay_records_only_the_delivered_chunks( + self, tmp_path: Path + ) -> None: + """The provider keeps sending, but the proxy the edge relays to hangs up after + two chunks. The chunk whose downstream write never landed must stay out of the + recording, or replay would hand back a byte the record run never delivered. + + Driven through the pure ``handle_edge_request`` core because a socket client + cannot force these tiny chunks to block mid-write, so closing the relay + generator is the faithful stand-in for the downstream write raising: it lands + the generator on the same suspended yield a broken pipe would.""" + root = tmp_path / "bundle" + with chunked_provider() as provider: + outcome = handle_edge_request( + record_backend(root), + {"openai": provider_url(provider)}, + "POST", + STREAM_PATH, + {"content-type": "application/json"}, + STREAM_BODY, + timeout=10.0, + ) + assert isinstance(outcome, EdgeStream) + steps = outcome.steps + first = next(steps) + second = next(steps) + assert isinstance(first, StreamChunk) and isinstance(second, StreamChunk) + assert (first.data, second.data) == (SSE_CHUNKS[0], SSE_CHUNKS[1]) + steps.close() + + recorded = recorded_stream(root) + assert recorded.status_code == 200 + assert stream_chunks(recorded) == [SSE_CHUNKS[0]] + assert recorded.truncated == "downstream: relay closed after 1 chunks" + def test_a_truncated_recording_replays_as_a_truncated_stream(self, tmp_path: Path) -> None: root = tmp_path / "bundle" record_stream(root, abort_after=2) From a8f24c856820ec013fed4e15b2e834797b70ee82 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Mon, 24 Aug 2026 13:35:34 -0700 Subject: [PATCH 14/20] test(vertex-passthrough): send the virtual key via x-litellm-api-key in streaming tests The LIT-4761 streaming-classification tests passed only the bring-your-own Google OAuth token in Authorization and mocked get_litellm_virtual_key, a shape that cannot authenticate in production. The credential-less filter now resolves the caller key by auth precedence, so a lone Authorization value reads as the key and is stripped. Send the virtual key in x-litellm-api-key, matching a real request, so Authorization is preserved and the classification assertions run. --- .../pass_through_endpoints/test_llm_pass_through_endpoints.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py b/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py index 243aadfc981..4ace18c927b 100644 --- a/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py +++ b/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py @@ -3311,6 +3311,7 @@ class TestVertexRawPredictStreamingClassification: "path": f"/vertex_ai/{endpoint}", "headers": [ (b"content-type", b"application/json"), + (b"x-litellm-api-key", b"test-key"), (b"authorization", b"Bearer ya29.byo-google-oauth"), ], "query_string": b"", From 2fe1e7e43f092cd38f2c031b40d3c3497d3067a2 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Mon, 24 Aug 2026 13:47:42 -0700 Subject: [PATCH 15/20] fix(vertex-passthrough): cover operator-configured pass-through key headers user_api_key_auth also accepts the caller key from a pass_through_endpoints entry's headers.litellm_user_api_key, not just litellm_key_header_name. Drop every operator-configured caller-key header by name and treat them as top-precedence caller-key sources, so a virtual key sent through one is never forwarded to Google. --- .../llm_passthrough_endpoints.py | 72 ++++++++++++------- .../test_llm_pass_through_endpoints.py | 20 ++++++ 2 files changed, 67 insertions(+), 25 deletions(-) diff --git a/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py b/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py index 4a712a6796e..a8b183ddd17 100644 --- a/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py +++ b/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py @@ -1769,26 +1769,46 @@ _VERTEX_CALLER_KEY_HEADER_PRECEDENCE: Final = ( ) +def _operator_configured_caller_key_header_names() -> tuple[str, ...]: + """Lowercased header names the operator has configured as caller-key sources. + + ``user_api_key_auth`` accepts the caller's key from two runtime-configured + headers beyond the built-in ones: ``general_settings.litellm_key_header_name``, + and each ``general_settings.pass_through_endpoints`` entry's + ``headers.litellm_user_api_key``. Google never consumes either, so they are + both dropped by name and treated as top-precedence caller-key sources. + """ + from litellm.proxy.proxy_server import general_settings + + custom_key_header: Final = general_settings.get("litellm_key_header_name") + pass_through_endpoints: Final = general_settings.get("pass_through_endpoints") + endpoints: Final = pass_through_endpoints if isinstance(pass_through_endpoints, list) else () + pass_through_key_headers: Final = tuple( + headers["litellm_user_api_key"] + for endpoint in endpoints + if isinstance(endpoint, dict) + for headers in (endpoint.get("headers"),) + if isinstance(headers, dict) and isinstance(headers.get("litellm_user_api_key"), str) + ) + configured: Final = ((custom_key_header,) if isinstance(custom_key_header, str) else ()) + pass_through_key_headers + return tuple(dict.fromkeys(name.lower() for name in configured)) + + def _authenticated_caller_key_values(request: Request) -> frozenset[str]: """The value ``user_api_key_auth`` would accept as this caller's LiteLLM key. The Vertex route authenticates through ``Depends(user_api_key_auth)``, which resolves the key from the first present of the credential headers in ``get_api_key``'s precedence order, with the operator-configured - ``general_settings.litellm_key_header_name`` overriding all of them. Some of - those headers (``Authorization``, ``x-goog-api-key``) are also kept as genuine - bring-your-own Google credentials, so returning only the value that actually - authenticated lets the filter strip that value wherever it appears while - leaving a real Google credential in place. An empty set means no caller key - was found, so nothing is value-stripped. + ``litellm_key_header_name`` / ``pass_through_endpoints`` headers overriding all + of them. Some of those headers (``Authorization``, ``x-goog-api-key``) are also + kept as genuine bring-your-own Google credentials, so returning only the value + that actually authenticated lets the filter strip that value wherever it + appears while leaving a real Google credential in place. An empty set means no + caller key was found, so nothing is value-stripped. """ - from litellm.proxy.proxy_server import general_settings - incoming: Final = _safe_get_request_headers(request) - custom_key_header_name: Final = (general_settings.get("litellm_key_header_name") or "").lower() - ordered_names: Final = ( - (custom_key_header_name,) if custom_key_header_name else () - ) + _VERTEX_CALLER_KEY_HEADER_PRECEDENCE + ordered_names: Final = _operator_configured_caller_key_header_names() + _VERTEX_CALLER_KEY_HEADER_PRECEDENCE present_values: Final = (incoming[name] for name in ordered_names if incoming.get(name)) authenticated_key: Final = next( (stripped for value in present_values if (stripped := _normalize_credential_value(value))), @@ -1808,26 +1828,28 @@ def _forwarded_headers_for_credentialless_vertex_passthrough(request: Request) - authenticates with an OAuth token in ``Authorization`` or an API key in ``x-goog-api-key``. So the proxy-only auth headers Google never consumes (everything in that set except those two, e.g. ``x-litellm-api-key`` / - ``api-key`` / ``x-api-key`` / ``Ocp-Apim-Subscription-Key``) are dropped by - name. ``Authorization`` and ``x-goog-api-key`` may instead carry a genuine - bring-your-own Google credential, so they are kept unless their value is the - caller's authenticated LiteLLM key, which is dropped by value (normalizing any - ``Bearer`` / ``Basic`` / ``AWS4`` auth-scheme prefix the same way authentication does). Because the value that authenticated is resolved by the - same precedence ``user_api_key_auth`` uses, a virtual key sent only in - ``x-goog-api-key`` (or in the operator-configured ``litellm_key_header_name``) - is dropped too, while a real Google key in ``x-goog-api-key`` alongside a - virtual key in a higher-precedence header is preserved. When neither a - surviving ``Authorization`` nor ``x-goog-api-key`` remains the request is - rejected so the virtual key cannot leak upstream. + ``api-key`` / ``x-api-key`` / ``Ocp-Apim-Subscription-Key``, plus any + operator-configured ``litellm_key_header_name`` / ``pass_through_endpoints`` + key header) are dropped by name. ``Authorization`` and ``x-goog-api-key`` may + instead carry a genuine bring-your-own Google credential, so they are kept + unless their value is the caller's authenticated LiteLLM key, which is dropped + by value (normalizing any ``Bearer`` / ``Basic`` / ``AWS4`` auth-scheme prefix + the same way authentication does). Because the value that authenticated is + resolved by the same precedence ``user_api_key_auth`` uses, a virtual key sent + only in ``x-goog-api-key`` (or in an operator-configured key header) is dropped + too, while a real Google key in ``x-goog-api-key`` alongside a virtual key in a + higher-precedence header is preserved. When neither a surviving + ``Authorization`` nor ``x-goog-api-key`` remains the request is rejected so the + virtual key cannot leak upstream. """ incoming: Final = _safe_get_request_headers(request) caller_key_values: Final = _authenticated_caller_key_values(request) + never_forwarded: Final = _HEADERS_NEVER_FORWARDED_TO_VERTEX.union(_operator_configured_caller_key_header_names()) forwarded: Final = MappingProxyType( { name: value for name, value in incoming.items() - if name not in _HEADERS_NEVER_FORWARDED_TO_VERTEX - and _normalize_credential_value(value) not in caller_key_values + if name not in never_forwarded and _normalize_credential_value(value) not in caller_key_values } ) if "authorization" not in forwarded and "x-goog-api-key" not in forwarded: diff --git a/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py b/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py index 4ace18c927b..35055f56a08 100644 --- a/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py +++ b/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py @@ -3698,6 +3698,26 @@ class TestVertexCredentiallessPassthroughVirtualKeyLeak: assert forwarded is None, "a virtual key in the custom auth header must not satisfy the gate nor be forwarded" assert raised is not None and raised.status_code == 401 + @pytest.mark.asyncio + async def test_virtual_key_in_pass_through_configured_header_is_stripped(self, monkeypatch): + with mock.patch.dict( # test-quality-ok: general_settings is the real proxy config surface for pass_through_endpoints; no injection seam exists on this route + "litellm.proxy.proxy_server.general_settings", + {"pass_through_endpoints": [{"headers": {"litellm_user_api_key": "x-company-key"}}]}, + ): + raised, forwarded = await self._run( + monkeypatch, + [ + (b"x-company-key", f"Bearer {self.VKEY}".encode()), + (b"x-goog-api-key", b"AIza-real-google-api-key"), + (b"content-type", b"application/json"), + ], + ) + assert raised is None + assert forwarded is not None + assert forwarded.get("x-goog-api-key") == "AIza-real-google-api-key" + assert "x-company-key" not in forwarded + assert self.VKEY not in " ".join(f"{name}:{value}" for name, value in forwarded.items()) + class TestGetAzureAISearchIndexFromEndpoint: """The operable index is only the segment right after ``indexes``. From fcc047bf8a99f49a9f1309382abd05501cdd1372 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Mon, 24 Aug 2026 13:57:37 -0700 Subject: [PATCH 16/20] fix(vertex-passthrough): match get_api_key precedence for configured key headers The resolver placed both operator-configured key headers at the top of its precedence, but user_api_key_auth only overrides with litellm_key_header_name; a pass_through_endpoints litellm_user_api_key is checked last. So a request that authenticated via Authorization while also sending a pass-through header could have the wrong value chosen, leaving the authenticated Authorization key forwarded. Order the resolver exactly like get_api_key: override first, built-in headers next, pass-through header last. --- .../llm_passthrough_endpoints.py | 50 +++++++++++-------- .../test_llm_pass_through_endpoints.py | 20 +++++++- 2 files changed, 48 insertions(+), 22 deletions(-) diff --git a/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py b/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py index a8b183ddd17..16fe5132470 100644 --- a/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py +++ b/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py @@ -1769,29 +1769,33 @@ _VERTEX_CALLER_KEY_HEADER_PRECEDENCE: Final = ( ) -def _operator_configured_caller_key_header_names() -> tuple[str, ...]: - """Lowercased header names the operator has configured as caller-key sources. +def _operator_configured_caller_key_header_names() -> tuple[tuple[str, ...], tuple[str, ...]]: + """Operator-configured caller-key header names, as (override, pass_through). ``user_api_key_auth`` accepts the caller's key from two runtime-configured - headers beyond the built-in ones: ``general_settings.litellm_key_header_name``, - and each ``general_settings.pass_through_endpoints`` entry's - ``headers.litellm_user_api_key``. Google never consumes either, so they are - both dropped by name and treated as top-precedence caller-key sources. + header sources beyond the built-in ones, at opposite ends of its precedence. + ``general_settings.litellm_key_header_name`` overrides every built-in source + (it replaces the resolved key after ``get_api_key`` runs), so it is highest + precedence. Each ``general_settings.pass_through_endpoints`` entry's + ``headers.litellm_user_api_key`` is checked last inside ``get_api_key``, so it + is lowest. Google never consumes either, so both are also dropped by name. """ from litellm.proxy.proxy_server import general_settings custom_key_header: Final = general_settings.get("litellm_key_header_name") + override: Final = (custom_key_header.lower(),) if isinstance(custom_key_header, str) else () pass_through_endpoints: Final = general_settings.get("pass_through_endpoints") endpoints: Final = pass_through_endpoints if isinstance(pass_through_endpoints, list) else () - pass_through_key_headers: Final = tuple( - headers["litellm_user_api_key"] - for endpoint in endpoints - if isinstance(endpoint, dict) - for headers in (endpoint.get("headers"),) - if isinstance(headers, dict) and isinstance(headers.get("litellm_user_api_key"), str) + pass_through: Final = tuple( + dict.fromkeys( + headers["litellm_user_api_key"].lower() + for endpoint in endpoints + if isinstance(endpoint, dict) + for headers in (endpoint.get("headers"),) + if isinstance(headers, dict) and isinstance(headers.get("litellm_user_api_key"), str) + ) ) - configured: Final = ((custom_key_header,) if isinstance(custom_key_header, str) else ()) + pass_through_key_headers - return tuple(dict.fromkeys(name.lower() for name in configured)) + return override, pass_through def _authenticated_caller_key_values(request: Request) -> frozenset[str]: @@ -1799,16 +1803,19 @@ def _authenticated_caller_key_values(request: Request) -> frozenset[str]: The Vertex route authenticates through ``Depends(user_api_key_auth)``, which resolves the key from the first present of the credential headers in - ``get_api_key``'s precedence order, with the operator-configured - ``litellm_key_header_name`` / ``pass_through_endpoints`` headers overriding all - of them. Some of those headers (``Authorization``, ``x-goog-api-key``) are also - kept as genuine bring-your-own Google credentials, so returning only the value - that actually authenticated lets the filter strip that value wherever it + ``get_api_key``'s precedence order. That precedence is matched here exactly: an + operator ``litellm_key_header_name`` overrides everything so it comes first, + then the built-in headers in ``get_api_key`` order, then a + ``pass_through_endpoints`` ``litellm_user_api_key`` header which ``get_api_key`` + checks last. Some of those headers (``Authorization``, ``x-goog-api-key``) are + also kept as genuine bring-your-own Google credentials, so returning only the + value that actually authenticated lets the filter strip that value wherever it appears while leaving a real Google credential in place. An empty set means no caller key was found, so nothing is value-stripped. """ incoming: Final = _safe_get_request_headers(request) - ordered_names: Final = _operator_configured_caller_key_header_names() + _VERTEX_CALLER_KEY_HEADER_PRECEDENCE + override_headers, pass_through_headers = _operator_configured_caller_key_header_names() + ordered_names: Final = override_headers + _VERTEX_CALLER_KEY_HEADER_PRECEDENCE + pass_through_headers present_values: Final = (incoming[name] for name in ordered_names if incoming.get(name)) authenticated_key: Final = next( (stripped for value in present_values if (stripped := _normalize_credential_value(value))), @@ -1844,7 +1851,8 @@ def _forwarded_headers_for_credentialless_vertex_passthrough(request: Request) - """ incoming: Final = _safe_get_request_headers(request) caller_key_values: Final = _authenticated_caller_key_values(request) - never_forwarded: Final = _HEADERS_NEVER_FORWARDED_TO_VERTEX.union(_operator_configured_caller_key_header_names()) + override_headers, pass_through_headers = _operator_configured_caller_key_header_names() + never_forwarded: Final = _HEADERS_NEVER_FORWARDED_TO_VERTEX.union(override_headers).union(pass_through_headers) forwarded: Final = MappingProxyType( { name: value diff --git a/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py b/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py index 35055f56a08..8ab99103bc5 100644 --- a/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py +++ b/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py @@ -3699,7 +3699,7 @@ class TestVertexCredentiallessPassthroughVirtualKeyLeak: assert raised is not None and raised.status_code == 401 @pytest.mark.asyncio - async def test_virtual_key_in_pass_through_configured_header_is_stripped(self, monkeypatch): + async def test_virtual_key_in_pass_through_configured_header_is_dropped_and_rejected(self, monkeypatch): with mock.patch.dict( # test-quality-ok: general_settings is the real proxy config surface for pass_through_endpoints; no injection seam exists on this route "litellm.proxy.proxy_server.general_settings", {"pass_through_endpoints": [{"headers": {"litellm_user_api_key": "x-company-key"}}]}, @@ -3708,6 +3708,23 @@ class TestVertexCredentiallessPassthroughVirtualKeyLeak: monkeypatch, [ (b"x-company-key", f"Bearer {self.VKEY}".encode()), + (b"content-type", b"application/json"), + ], + ) + assert forwarded is None, "a virtual key in the pass-through key header must be dropped, not forwarded" + assert raised is not None and raised.status_code == 401 + + @pytest.mark.asyncio + async def test_authenticated_authorization_is_stripped_over_a_lower_precedence_pass_through_header(self, monkeypatch): + with mock.patch.dict( # test-quality-ok: general_settings is the real proxy config surface for pass_through_endpoints; no injection seam exists on this route + "litellm.proxy.proxy_server.general_settings", + {"pass_through_endpoints": [{"headers": {"litellm_user_api_key": "x-company-key"}}]}, + ): + raised, forwarded = await self._run( + monkeypatch, + [ + (b"authorization", f"Bearer {self.VKEY}".encode()), + (b"x-company-key", b"sk-decoy-lower-precedence-value"), (b"x-goog-api-key", b"AIza-real-google-api-key"), (b"content-type", b"application/json"), ], @@ -3715,6 +3732,7 @@ class TestVertexCredentiallessPassthroughVirtualKeyLeak: assert raised is None assert forwarded is not None assert forwarded.get("x-goog-api-key") == "AIza-real-google-api-key" + assert "authorization" not in forwarded, "Authorization authenticated (higher precedence) so its key must be stripped" assert "x-company-key" not in forwarded assert self.VKEY not in " ".join(f"{name}:{value}" for name, value in forwarded.items()) From 5d34b1232e5c7aa57411e66b43d455ef1f283611 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Mon, 24 Aug 2026 14:17:18 -0700 Subject: [PATCH 17/20] fix(ci): ignore-list recursive form-field flatteners in recursive_detector The recursive_detector code-quality gate fails on litellm_internal_staging because _flatten_form_field and _flatten_form_data_field in llm_request_utils.py are recursive but absent from IGNORE_FUNCTIONS. Both are bounded structural recursion over an already-parsed JSON-shaped request body (a finite tree, no cycles possible), matching the existing ignored walkers, so add them to the ignore list with a justification comment. --- tests/code_coverage_tests/recursive_detector.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/code_coverage_tests/recursive_detector.py b/tests/code_coverage_tests/recursive_detector.py index 5bd6326d8f2..b15a16ffc23 100644 --- a/tests/code_coverage_tests/recursive_detector.py +++ b/tests/code_coverage_tests/recursive_detector.py @@ -60,6 +60,8 @@ IGNORE_FUNCTIONS = [ "json_string_leaves", # max depth set (MAX_STRUCTURED_CONTENT_SCAN_DEPTH); fails closed by raising at the cap so nothing goes unscanned. "with_json_string_leaves", # transitively bounded: only runs on a tree json_string_leaves already walked under the cap. "json_unrewritable_labels", # max depth set (MAX_STRUCTURED_CONTENT_SCAN_DEPTH); returns the None sentinel at the cap so the caller blocks. + "_flatten_form_field", # bounded by the nesting depth of the already-parsed request body (a finite JSON tree, no cycles possible). + "_flatten_form_data_field", # bounded by the nesting depth of the already-parsed request body (a finite JSON tree, no cycles possible). ] From 6147b3ce6ed6a7b675bd9d2f5d2b2c25ad7c80b1 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Mon, 24 Aug 2026 14:30:32 -0700 Subject: [PATCH 18/20] refactor(ui): install the shadcn field primitive (#38126) * refactor(ui): install the shadcn field primitive `components/shared/form/field.tsx` was the upstream base-vega `field` source living outside `components/ui/`. It exported the same ten symbols as upstream, so `npx shadcn add` could never update it and it had already drifted: its `FieldLabel` was missing the hover and focus-visible ring utilities upstream now ships for labels that wrap a nested field. Install the primitive and point the 77 importers at it. The copy is deleted rather than kept as a wrapper because it added nothing beyond `forwardRef`, which React 19 makes unnecessary since `ref` arrives as an ordinary prop. `field.test.tsx` moves next to the primitive with no edits to its contents, and its nineteen tests, ref assertions included, pass against the generated file. That is the evidence the swap is behaviour-preserving. Two nested-field call sites pick up the upstream hover and focus-visible styling that the stale copy had been missing. (cherry picked from commit 947f7fa674c83bfc57f43ad8bfc89c894da947a2) * test(ui): cover the nested-field interaction cues FieldLabel had lost The stale copy of `field` was missing the hover, focus-visible and disabled selectors upstream applies to a label that wraps a nested field, so installing the primitive restored them with nothing asserting they stay. Assert the class contract rather than the rendered effect. jsdom evaluates neither `:has()` nor `:focus-visible`, and Tailwind is not compiled under vitest, so a test that clicked or tabbed would pass on an element with no styling at all. Checking the utilities are present is the assertion that actually fails when they go missing, which is the way they were lost before. Verified by stripping the four selectors from the primitive: both tests fail, and both pass once it is restored. (cherry picked from commit 5a5dbf64270d9d1285dbc4a7af76bb3d927778a8) --- ui/litellm-dashboard/eslint-suppressions.json | 10 +- .../AccessGroupsModal/AccessGroupBaseForm.tsx | 2 +- .../AccessGroupCreateDialog.tsx | 2 +- .../admin-panel/_components/AdminPanel.tsx | 2 +- .../agents/_components/AgentFormKit.tsx | 2 +- .../agents/_components/add_agent_form.tsx | 2 +- .../agents/_components/agent_form_fields.tsx | 2 +- .../agents/_components/agent_info.tsx | 2 +- .../_components/dynamic_agent_form_fields.tsx | 2 +- .../budgets/_components/budget_modal.tsx | 2 +- .../budgets/_components/edit_budget_modal.tsx | 2 +- .../_components/PromptCompressionTab.tsx | 2 +- .../_components/add_margin_form.tsx | 2 +- .../_components/add_provider_form.tsx | 2 +- .../_components/GuardrailFormField.tsx | 2 +- .../_components/TeamGuardrailsTab.tsx | 2 +- .../_components/add_guardrail_form.tsx | 2 +- .../CompetitorIntentConfiguration.tsx | 2 +- .../guardrails/_components/guardrail_info.tsx | 2 +- .../_components/guardrail_provider_fields.tsx | 2 +- .../_components/llm_judge/LLMJudgeFields.tsx | 2 +- .../_components/MCPPermissionManagement.tsx | 2 +- .../_components/MCPToolsetsTab.tsx | 2 +- .../_components/ToolArgumentsForm.tsx | 2 +- .../_components/UserEnvVarsModal.tsx | 2 +- .../memory/_components/MemoryEditModal.tsx | 2 +- .../_components/add_attachment_form.tsx | 2 +- .../policies/_components/add_policy_form.tsx | 2 +- .../_components/policy_test_panel.tsx | 2 +- .../ProjectModals/ProjectBaseForm.tsx | 2 +- .../prompts/_components/add_prompt_form.tsx | 2 +- .../_components/CreateSearchTools.tsx | 2 +- .../search-tools/_components/SearchTools.tsx | 2 +- .../skills/_components/add_plugin_form.tsx | 2 +- .../_components/components/CreateTagModal.tsx | 2 +- .../tag-management/_components/tag_info.tsx | 2 +- .../DefaultUserSettingsForm.tsx | 2 +- .../users/_components/user_edit_view.tsx | 2 +- .../_components/view_users/user_info_view.tsx | 2 +- .../_components/CreateVectorStore.tsx | 2 +- .../_components/S3VectorsConfig.tsx | 2 +- .../_components/VectorStoreForm.tsx | 2 +- .../_components/vector_store_info.tsx | 2 +- .../src/app/login/LoginPage.tsx | 2 +- .../src/app/onboarding/OnboardingFormBody.tsx | 2 +- .../CloudZeroCreateModal.tsx | 2 +- .../CloudZeroUpdateModal.tsx | 2 +- .../src/components/CreateUserButton.tsx | 2 +- ui/litellm-dashboard/src/components/SCIM.tsx | 2 +- .../src/components/SSOModals.tsx | 2 +- .../EditHashicorpVaultModal.tsx | 2 +- .../LoggingSettings/LoggingSettings.tsx | 2 +- .../MCPSemanticFilterSettings.tsx | 2 +- .../PluginSettings/PluginSettings.tsx | 2 +- .../Modals/BaseSSOSettingsForm.tsx | 2 +- ui/litellm-dashboard/src/components/Teams.tsx | 2 +- .../src/components/UIAccessControlForm.tsx | 2 +- .../src/components/add_model/AddModelForm.tsx | 2 +- .../add_model/add_auto_router_tab.tsx | 2 +- .../src/components/cloudzero_export_modal.tsx | 2 +- .../common_components/MountedFormField.tsx | 2 +- .../PassThroughGuardrailsSection.tsx | 2 +- .../common_components/user_search_modal.tsx | 2 +- .../edit_auto_router_modal.tsx | 2 +- .../key_team_helpers/ModelMaxBudgetEditor.tsx | 2 +- .../mcp_tools/MCPToolArgumentsForm.tsx | 2 +- .../model_add/reuse_credentials.tsx | 2 +- .../ModelSettingsModal/ModelSettingsModal.tsx | 2 +- .../organisms/RegenerateKeyModal.tsx | 2 +- .../organisms/create_key_button.tsx | 2 +- .../org-create/OrgCreateDialog.tsx | 2 +- .../org-settings/OrgSettingsForm.tsx | 2 +- .../routing_groups/RoutingGroupModal.tsx | 2 +- .../src/components/settings.tsx | 2 +- .../src/components/shared/form/FormField.tsx | 2 +- .../src/components/team/EditMembership.tsx | 2 +- .../src/components/team/TeamInfo.tsx | 2 +- .../components/templates/key_edit_view.tsx | 2 +- .../{shared/form => ui}/field.test.tsx | 35 ++++ .../components/{shared/form => ui}/field.tsx | 167 +++++++++--------- .../update_model_credentials_modal.tsx | 2 +- 81 files changed, 199 insertions(+), 169 deletions(-) rename ui/litellm-dashboard/src/components/{shared/form => ui}/field.test.tsx (79%) rename ui/litellm-dashboard/src/components/{shared/form => ui}/field.tsx (59%) diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index 03de475c8c3..a647eedc354 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -2018,11 +2018,6 @@ "count": 1 } }, - "src/components/shared/form/field.tsx": { - "local/filename-pascal-case": { - "count": 1 - } - }, "src/components/shared/numerical_input.tsx": { "local/filename-pascal-case": { "count": 1 @@ -2198,6 +2193,11 @@ "count": 1 } }, + "src/components/ui/field.tsx": { + "local/filename-pascal-case": { + "count": 1 + } + }, "src/components/ui/hover-card.tsx": { "local/filename-pascal-case": { "count": 1 diff --git a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsModal/AccessGroupBaseForm.tsx b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsModal/AccessGroupBaseForm.tsx index c9ba082f7a6..f8ec3b5e1e7 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsModal/AccessGroupBaseForm.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsModal/AccessGroupBaseForm.tsx @@ -7,7 +7,7 @@ import { z } from "zod/v4"; import { useAgents } from "@/app/(dashboard)/hooks/agents/useAgents"; import { useMCPServers } from "@/app/(dashboard)/hooks/mcpServers/useMCPServers"; import { ModelSelect } from "@/components/ModelSelect/ModelSelect"; -import { FieldGroup } from "@/components/shared/form/field"; +import { FieldGroup } from "@/components/ui/field"; import { FormField } from "@/components/shared/form/FormField"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/access-group-create/AccessGroupCreateDialog.tsx b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/access-group-create/AccessGroupCreateDialog.tsx index 624c85cc818..a7f2ee18521 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/access-group-create/AccessGroupCreateDialog.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/access-group-create/AccessGroupCreateDialog.tsx @@ -9,7 +9,7 @@ import { useAgents } from "@/app/(dashboard)/hooks/agents/useAgents"; import { useMCPServers } from "@/app/(dashboard)/hooks/mcpServers/useMCPServers"; import { ModelSelect } from "@/components/ModelSelect/ModelSelect"; import { toast } from "@/lib/toast"; -import { FieldGroup } from "@/components/shared/form/field"; +import { FieldGroup } from "@/components/ui/field"; import { FormField } from "@/components/shared/form/FormField"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/admin-panel/_components/AdminPanel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/admin-panel/_components/AdminPanel.tsx index 976fb94acea..7dc3f57bd6f 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/admin-panel/_components/AdminPanel.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/admin-panel/_components/AdminPanel.tsx @@ -29,7 +29,7 @@ import { } from "@/components/Settings/AdminSettings/SSOSettings/Modals/BaseSSOSettingsForm"; import UIAccessControlForm from "@/components/UIAccessControlForm"; import { z } from "zod/v4"; -import { FieldGroup } from "@/components/shared/form/field"; +import { FieldGroup } from "@/components/ui/field"; import { FormField } from "@/components/shared/form/FormField"; import { Input } from "@/components/ui/input"; import { useZodForm } from "@/lib/forms/useZodForm"; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentFormKit.tsx b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentFormKit.tsx index e0152358f8c..15b85001cdc 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentFormKit.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentFormKit.tsx @@ -26,7 +26,7 @@ import { import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; import { Input } from "@/components/ui/input"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; -import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/shared/form/field"; +import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"; export interface AgentSkillFormValue { id?: string; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.tsx b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.tsx index 9e8ea64fc45..51445ec1bdb 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.tsx @@ -15,7 +15,7 @@ import { Separator } from "@/components/ui/separator"; import { Switch } from "@/components/ui/switch"; import { Textarea } from "@/components/ui/textarea"; import { TooltipProvider } from "@/components/ui/tooltip"; -import { Field, FieldGroup, FieldLabel } from "@/components/shared/form/field"; +import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"; import { SearchSelect } from "@/components/shared/SearchSelect"; import { createAgentCall, diff --git a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_form_fields.tsx b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_form_fields.tsx index 622e5ea7c5c..816df4d805c 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_form_fields.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_form_fields.tsx @@ -6,7 +6,7 @@ import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Switch } from "@/components/ui/switch"; import { Textarea } from "@/components/ui/textarea"; -import { Field, FieldGroup, FieldTitle } from "@/components/shared/form/field"; +import { Field, FieldGroup, FieldTitle } from "@/components/ui/field"; import { AGENT_FORM_CONFIG, SKILL_FIELD_CONFIG } from "./agent_config"; import CostConfigFields, { COST_FIELD_NAMES } from "./cost_config_fields"; import { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_info.tsx b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_info.tsx index a8d78f9f53a..eddeeec674b 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_info.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_info.tsx @@ -8,7 +8,7 @@ import { Separator } from "@/components/ui/separator"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { TooltipProvider } from "@/components/ui/tooltip"; import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner"; -import { Field, FieldGroup, FieldLabel } from "@/components/shared/form/field"; +import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"; import { toast } from "@/lib/toast"; import { ArrowLeft } from "lucide-react"; import { getAgentInfo, patchAgentCall, getAgentCreateMetadata, AgentCreateInfo } from "@/components/networking"; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/dynamic_agent_form_fields.tsx b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/dynamic_agent_form_fields.tsx index 85d066f4233..04a8b0df9d9 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/dynamic_agent_form_fields.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/dynamic_agent_form_fields.tsx @@ -2,7 +2,7 @@ import React from "react"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Textarea } from "@/components/ui/textarea"; -import { FieldGroup } from "@/components/shared/form/field"; +import { FieldGroup } from "@/components/ui/field"; import { AgentCreateInfo, AgentCredentialFieldMetadata } from "@/components/networking"; import { PasswordInput } from "@/components/shared/PasswordInput"; import { AGENT_FORM_CONFIG } from "./agent_config"; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/budget_modal.tsx b/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/budget_modal.tsx index 5761d7308cd..50f9c7cda3c 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/budget_modal.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/budget_modal.tsx @@ -4,7 +4,7 @@ import { z } from "zod/v4"; import { useCreateBudget } from "@/app/(dashboard)/hooks/budgets/useBudgets"; import { applyBudgetPrecision } from "./budgetPrecision"; import { toast } from "@/lib/toast"; -import { FieldGroup } from "@/components/shared/form/field"; +import { FieldGroup } from "@/components/ui/field"; import { FormField } from "@/components/shared/form/FormField"; import { Button } from "@/components/ui/button"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/edit_budget_modal.tsx b/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/edit_budget_modal.tsx index 3f9de881710..f4597a72fbb 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/edit_budget_modal.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/edit_budget_modal.tsx @@ -5,7 +5,7 @@ import { useUpdateBudget } from "@/app/(dashboard)/hooks/budgets/useBudgets"; import { budgetItem } from "@/app/(dashboard)/hooks/budgets/useBudgets"; import { applyBudgetPrecision } from "./budgetPrecision"; import { toast } from "@/lib/toast"; -import { FieldGroup } from "@/components/shared/form/field"; +import { FieldGroup } from "@/components/ui/field"; import { FormField } from "@/components/shared/form/FormField"; import { Button } from "@/components/ui/button"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/PromptCompressionTab.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/PromptCompressionTab.tsx index 23700470683..eb0d1ada42e 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/PromptCompressionTab.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/PromptCompressionTab.tsx @@ -6,7 +6,7 @@ import { z } from "zod/v4"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { createGuardrailCall, getGuardrailsList } from "@/components/networking"; -import { FieldGroup } from "@/components/shared/form/field"; +import { FieldGroup } from "@/components/ui/field"; import { FormField } from "@/components/shared/form/FormField"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/add_margin_form.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/add_margin_form.tsx index c307c176122..4b5c3e77252 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/add_margin_form.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/add_margin_form.tsx @@ -2,7 +2,7 @@ import React from "react"; import { CircleHelp } from "lucide-react"; import { Providers, provider_map } from "@/components/provider_info_helpers"; import { Logo } from "@/components/molecules/logo/Logo"; -import { Field, FieldLabel, FieldTitle } from "@/components/shared/form/field"; +import { Field, FieldLabel, FieldTitle } from "@/components/ui/field"; import { Button } from "@/components/ui/button"; import { Combobox, diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/add_provider_form.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/add_provider_form.tsx index 4f4edf6ff30..05748652180 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/add_provider_form.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/add_provider_form.tsx @@ -3,7 +3,7 @@ import { CircleHelp } from "lucide-react"; import { Logo } from "@/components/molecules/logo/Logo"; import { Providers, provider_map } from "@/components/provider_info_helpers"; -import { Field, FieldGroup, FieldLabel } from "@/components/shared/form/field"; +import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"; import { Button } from "@/components/ui/button"; import { Combobox, diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/GuardrailFormField.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/GuardrailFormField.tsx index 0edce78b8fd..0afd9aab0cf 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/GuardrailFormField.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/GuardrailFormField.tsx @@ -3,7 +3,7 @@ import { CircleHelp } from "lucide-react"; import React, { useId } from "react"; import { useController, type Control, type ControllerRenderProps, type RegisterOptions } from "react-hook-form"; -import { Field, FieldDescription, FieldError, FieldLabel } from "@/components/shared/form/field"; +import { Field, FieldDescription, FieldError, FieldLabel } from "@/components/ui/field"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/TeamGuardrailsTab.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/TeamGuardrailsTab.tsx index c9de758f50d..fae21f8dfc4 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/TeamGuardrailsTab.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/TeamGuardrailsTab.tsx @@ -30,7 +30,7 @@ import TeamDropdown from "@/components/common_components/team_dropdown"; import { useRegisterGuardrail } from "@/app/(dashboard)/hooks/guardrails/useRegisterGuardrail"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import { isProxyAdminRole } from "@/utils/roles"; -import { FieldGroup } from "@/components/shared/form/field"; +import { FieldGroup } from "@/components/ui/field"; import { FormField } from "@/components/shared/form/FormField"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/add_guardrail_form.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/add_guardrail_form.tsx index 47326786890..29df7c8bf3d 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/add_guardrail_form.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/add_guardrail_form.tsx @@ -25,7 +25,7 @@ import { } from "./guardrail_info_helpers"; import { Logo } from "@/components/molecules/logo/Logo"; import { MultiSelect } from "@/components/shared/MultiSelect"; -import { FieldGroup } from "@/components/shared/form/field"; +import { FieldGroup } from "@/components/ui/field"; import { Button } from "@/components/ui/button"; import { Combobox, diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/CompetitorIntentConfiguration.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/CompetitorIntentConfiguration.tsx index d77dd216c2a..8445495246d 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/CompetitorIntentConfiguration.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/CompetitorIntentConfiguration.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useId, useState } from "react"; import { getMajorAirlines } from "@/components/networking"; -import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/shared/form/field"; +import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field"; import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Switch } from "@/components/ui/switch"; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_info.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_info.tsx index 07e75511ecb..f1b5a0c61ec 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_info.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_info.tsx @@ -14,7 +14,7 @@ import React, { useCallback, useEffect, useLayoutEffect, useState } from "react" import { useForm } from "react-hook-form"; import { toast } from "@/lib/toast"; import { Logo } from "@/components/molecules/logo/Logo"; -import { FieldGroup } from "@/components/shared/form/field"; +import { FieldGroup } from "@/components/ui/field"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_provider_fields.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_provider_fields.tsx index 632c2a6b0df..76c91d4c176 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_provider_fields.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_provider_fields.tsx @@ -9,7 +9,7 @@ import { getGuardrailProviderSpecificParams } from "@/components/networking"; import { MultiSelect } from "@/components/shared/MultiSelect"; import { PasswordInput } from "@/components/shared/PasswordInput"; import NumericalInput from "@/components/shared/numerical_input"; -import { FieldGroup } from "@/components/shared/form/field"; +import { FieldGroup } from "@/components/ui/field"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Slider } from "@/components/ui/slider"; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/llm_judge/LLMJudgeFields.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/llm_judge/LLMJudgeFields.tsx index a7a247e1495..256049975d3 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/llm_judge/LLMJudgeFields.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/llm_judge/LLMJudgeFields.tsx @@ -3,7 +3,7 @@ import { Plus, X } from "lucide-react"; import React from "react"; import { useController } from "react-hook-form"; -import { Field, FieldGroup, FieldLabel } from "@/components/shared/form/field"; +import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"; import { Button } from "@/components/ui/button"; import { Combobox, diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPPermissionManagement.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPPermissionManagement.tsx index da264063839..cb423b435ae 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPPermissionManagement.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPPermissionManagement.tsx @@ -16,7 +16,7 @@ import { type MountedFormValues, } from "@/components/common_components/MountedFormField"; import { requiredRule } from "@/components/common_components/formRules"; -import { Field, FieldLabel } from "@/components/shared/form/field"; +import { Field, FieldLabel } from "@/components/ui/field"; import { invertedSwitchControl, switchControl, tagsControl, textControl } from "./mcpFieldRules"; import { listControl } from "./mcpFormStore"; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPToolsetsTab.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPToolsetsTab.tsx index c077a513757..c637655d665 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPToolsetsTab.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPToolsetsTab.tsx @@ -14,7 +14,7 @@ import { getProxyBaseUrl, } from "@/components/networking"; import { MCPToolset, MCPToolsetTool } from "@/components/mcp_tools/types"; -import { FieldGroup } from "@/components/shared/form/field"; +import { FieldGroup } from "@/components/ui/field"; import { FormField } from "@/components/shared/form/FormField"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/ToolArgumentsForm.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/ToolArgumentsForm.tsx index 14d69c358ba..90d38642a5d 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/ToolArgumentsForm.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/ToolArgumentsForm.tsx @@ -7,7 +7,7 @@ import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; -import { FieldGroup } from "@/components/shared/form/field"; +import { FieldGroup } from "@/components/ui/field"; import { FormField, type FormFieldControlProps } from "@/components/shared/form/FormField"; import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner"; import type { InputSchemaProperty } from "@/components/mcp_tools/types"; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/UserEnvVarsModal.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/UserEnvVarsModal.tsx index 66b33ff2db8..5d871664314 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/UserEnvVarsModal.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/UserEnvVarsModal.tsx @@ -5,7 +5,7 @@ import { z } from "zod/v4"; import { MCPServer, MCPUserEnvVarsStatus, MCPUserEnvVarSpec } from "@/components/mcp_tools/types"; import { getMCPUserEnvVars, storeMCPUserEnvVars } from "@/components/networking"; import { toast } from "@/lib/toast"; -import { FieldGroup } from "@/components/shared/form/field"; +import { FieldGroup } from "@/components/ui/field"; import { FormField } from "@/components/shared/form/FormField"; import { Alert, AlertTitle } from "@/components/shared/Alert"; import { PasswordInput } from "@/components/shared/PasswordInput"; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryEditModal.tsx b/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryEditModal.tsx index cd238406237..33b75286969 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryEditModal.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryEditModal.tsx @@ -5,7 +5,7 @@ import React, { useEffect, useState } from "react"; import { z } from "zod/v4"; import type { MemoryRow } from "@/components/networking"; -import { FieldGroup } from "@/components/shared/form/field"; +import { FieldGroup } from "@/components/ui/field"; import { FormField } from "@/components/shared/form/FormField"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/add_attachment_form.tsx b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/add_attachment_form.tsx index 83dee5c7075..06b11701b2a 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/add_attachment_form.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/add_attachment_form.tsx @@ -5,7 +5,7 @@ import { Policy } from "@/components/policies/types"; import { teamListCall, keyListCall, modelAvailableCall, estimateAttachmentImpactCall } from "@/components/networking"; import { toast } from "@/lib/toast"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; -import { FieldGroup, FieldLabel, FieldTitle } from "@/components/shared/form/field"; +import { FieldGroup, FieldLabel, FieldTitle } from "@/components/ui/field"; import { FormField } from "@/components/shared/form/FormField"; import { Button } from "@/components/ui/button"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/add_policy_form.tsx b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/add_policy_form.tsx index 403562bc7a3..097a6389721 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/add_policy_form.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/add_policy_form.tsx @@ -7,7 +7,7 @@ import { toast } from "@/lib/toast"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import { MultiSelect } from "@/components/shared/MultiSelect"; import { SearchSelect } from "@/components/shared/SearchSelect"; -import { FieldGroup } from "@/components/shared/form/field"; +import { FieldGroup } from "@/components/ui/field"; import { FormField } from "@/components/shared/form/FormField"; import { Badge } from "@/components/ui/badge"; import { StatusBadge } from "@/components/shared/table_cells/status_badge"; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/policy_test_panel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/policy_test_panel.tsx index b32a1b6a6e7..a89182d0a0e 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/policy_test_panel.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/policy_test_panel.tsx @@ -4,7 +4,7 @@ import { CircleAlert, Inbox } from "lucide-react"; import { Alert, AlertDescription, AlertTitle } from "@/components/shared/Alert"; import { resolvePoliciesCall, teamListCall, keyListCall, modelAvailableCall } from "@/components/networking"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; -import { FieldGroup } from "@/components/shared/form/field"; +import { FieldGroup } from "@/components/ui/field"; import { FormField } from "@/components/shared/form/FormField"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/ProjectBaseForm.tsx b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/ProjectBaseForm.tsx index a18b5ecfb98..fed1a88fe98 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/ProjectBaseForm.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/ProjectBaseForm.tsx @@ -14,7 +14,7 @@ import { getGuardrailsList } from "@/components/networking"; import { TagsInput } from "@/app/(dashboard)/guardrails/_components/content_filter/TagsInput"; import { Alert, AlertTitle } from "@/components/shared/Alert"; import { SearchSelect } from "@/components/shared/SearchSelect"; -import { FieldGroup } from "@/components/shared/form/field"; +import { FieldGroup } from "@/components/ui/field"; import { FormField } from "@/components/shared/form/FormField"; import { Button } from "@/components/ui/button"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/add_prompt_form.tsx b/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/add_prompt_form.tsx index 2deda4b30ee..65079a55f85 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/add_prompt_form.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/add_prompt_form.tsx @@ -3,7 +3,7 @@ import { Upload as UploadIcon, X } from "lucide-react"; import { z } from "zod/v4"; import { convertPromptFileToJson, createPromptCall } from "@/components/networking"; import { toast } from "@/lib/toast"; -import { Field, FieldDescription, FieldGroup, FieldSeparator, FieldTitle } from "@/components/shared/form/field"; +import { Field, FieldDescription, FieldGroup, FieldSeparator, FieldTitle } from "@/components/ui/field"; import { FormField } from "@/components/shared/form/FormField"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/CreateSearchTools.tsx b/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/CreateSearchTools.tsx index 57148ee3a21..c094cd903fc 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/CreateSearchTools.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/CreateSearchTools.tsx @@ -8,7 +8,7 @@ import { Logo } from "@/components/molecules/logo/Logo"; import { toast } from "@/lib/toast"; import { createSearchTool, fetchAvailableSearchProviders } from "@/components/networking"; import { PasswordInput } from "@/components/shared/PasswordInput"; -import { FieldGroup } from "@/components/shared/form/field"; +import { FieldGroup } from "@/components/ui/field"; import { FormField } from "@/components/shared/form/FormField"; import { Button } from "@/components/ui/button"; import { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/SearchTools.tsx b/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/SearchTools.tsx index e171ed9969b..eddee602f0b 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/SearchTools.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/SearchTools.tsx @@ -11,7 +11,7 @@ import { updateSearchTool, } from "@/components/networking"; import { PasswordInput } from "@/components/shared/PasswordInput"; -import { FieldGroup } from "@/components/shared/form/field"; +import { FieldGroup } from "@/components/ui/field"; import { FormField } from "@/components/shared/form/FormField"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/add_plugin_form.tsx b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/add_plugin_form.tsx index b3e47a63dc6..70669ffc0cc 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/add_plugin_form.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/add_plugin_form.tsx @@ -3,7 +3,7 @@ import { CircleHelp } from "lucide-react"; import { z } from "zod/v4"; import { toast } from "@/lib/toast"; import { registerClaudeCodePlugin } from "@/components/networking"; -import { FieldGroup } from "@/components/shared/form/field"; +import { FieldGroup } from "@/components/ui/field"; import { FormField } from "@/components/shared/form/FormField"; import { Button } from "@/components/ui/button"; import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner"; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/components/CreateTagModal.tsx b/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/components/CreateTagModal.tsx index 55b8752aab0..6f6bdcb6fe4 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/components/CreateTagModal.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/components/CreateTagModal.tsx @@ -4,7 +4,7 @@ import { ChevronRight, CircleHelp } from "lucide-react"; import React from "react"; import { z } from "zod/v4"; import BudgetDurationDropdown from "@/components/common_components/budget_duration_dropdown"; -import { FieldGroup } from "@/components/shared/form/field"; +import { FieldGroup } from "@/components/ui/field"; import { FormField } from "@/components/shared/form/FormField"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { MultiSelect } from "@/components/shared/MultiSelect"; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/tag_info.tsx b/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/tag_info.tsx index 6397d3e2819..e8cb358c0cf 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/tag_info.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/tag_info.tsx @@ -10,7 +10,7 @@ import { Tag, TagUpdateRequest } from "@/components/tag_management/types"; import { toast } from "@/lib/toast"; import NumericalInput from "@/components/shared/numerical_input"; import BudgetDurationDropdown from "@/components/common_components/budget_duration_dropdown"; -import { FieldGroup } from "@/components/shared/form/field"; +import { FieldGroup } from "@/components/ui/field"; import { FormField } from "@/components/shared/form/FormField"; import { MultiSelect } from "@/components/shared/MultiSelect"; import { Badge } from "@/components/ui/badge"; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/default-user-settings/DefaultUserSettingsForm.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/default-user-settings/DefaultUserSettingsForm.tsx index d3eef4dcbbf..269f3b0af39 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/default-user-settings/DefaultUserSettingsForm.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/default-user-settings/DefaultUserSettingsForm.tsx @@ -8,7 +8,7 @@ import { useInfiniteTeams } from "@/app/(dashboard)/hooks/teams/useTeams"; import { ModelSelect, MODEL_SENTINEL_OPTIONS } from "@/components/ModelSelect/ModelSelect"; import { toast } from "@/lib/toast"; import { PaginatedSearchSelect } from "@/components/shared/PaginatedSearchSelect"; -import { FieldGroup } from "@/components/shared/form/field"; +import { FieldGroup } from "@/components/ui/field"; import { FormField } from "@/components/shared/form/FormField"; import type { SearchSelectOption } from "@/components/shared/SearchSelect"; import { Button } from "@/components/ui/button"; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/user_edit_view.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/user_edit_view.tsx index ed0c08adf38..96771dc6dc4 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/user_edit_view.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/user_edit_view.tsx @@ -10,7 +10,7 @@ import MCPServerSelector from "@/components/mcp_server_management/MCPServerSelec import MCPToolPermissions from "@/components/mcp_server_management/MCPToolPermissions"; import type { ObjectPermission } from "@/components/object_permission_types"; import { MultiSelect } from "@/components/shared/MultiSelect"; -import { FieldGroup } from "@/components/shared/form/field"; +import { FieldGroup } from "@/components/ui/field"; import { FormField } from "@/components/shared/form/FormField"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/user_info_view.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/user_info_view.tsx index fdaa1ed3508..1b3f2c050b9 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/user_info_view.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/user_info_view.tsx @@ -18,7 +18,7 @@ import { Member, } from "@/components/networking"; import { SimpleTooltip } from "@/components/ui/tooltip"; -import { Field, FieldGroup, FieldLabel } from "@/components/shared/form/field"; +import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"; import { Combobox, ComboboxContent, diff --git a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/CreateVectorStore.tsx b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/CreateVectorStore.tsx index eae4a3fd799..9d447090381 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/CreateVectorStore.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/CreateVectorStore.tsx @@ -14,7 +14,7 @@ import { VectorStoreFieldConfig, } from "@/components/vector_store_providers"; import { Logo } from "@/components/molecules/logo/Logo"; -import { Field, FieldGroup, FieldLabel } from "@/components/shared/form/field"; +import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/S3VectorsConfig.tsx b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/S3VectorsConfig.tsx index 7243291d740..db549601212 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/S3VectorsConfig.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/S3VectorsConfig.tsx @@ -2,7 +2,7 @@ import React, { useState, useEffect } from "react"; import { CircleHelp, Info } from "lucide-react"; import { Alert, AlertDescription, AlertTitle } from "@/components/shared/Alert"; import { fetchAvailableModels, ModelGroup } from "@/components/llm_calls/fetch_models"; -import { Field, FieldError, FieldLabel } from "@/components/shared/form/field"; +import { Field, FieldError, FieldLabel } from "@/components/ui/field"; import { Combobox, ComboboxContent, diff --git a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/VectorStoreForm.tsx b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/VectorStoreForm.tsx index 0c7d8cebb4b..be0954a7d24 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/VectorStoreForm.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/VectorStoreForm.tsx @@ -15,7 +15,7 @@ import { import { Logo } from "@/components/molecules/logo/Logo"; import { fetchAvailableModels, ModelGroup } from "@/components/llm_calls/fetch_models"; import { toast } from "@/lib/toast"; -import { FieldGroup } from "@/components/shared/form/field"; +import { FieldGroup } from "@/components/ui/field"; import { FormField } from "@/components/shared/form/FormField"; import { Button } from "@/components/ui/button"; import { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/vector_store_info.tsx b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/vector_store_info.tsx index 638122a89e7..60e6154a951 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/vector_store_info.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/vector_store_info.tsx @@ -13,7 +13,7 @@ import { getVectorStoreProviderLogoAndName } from "@/components/vector_store_pro import { Logo } from "@/components/molecules/logo/Logo"; import VectorStoreTester from "./VectorStoreTester"; import { toast } from "@/lib/toast"; -import { FieldGroup } from "@/components/shared/form/field"; +import { FieldGroup } from "@/components/ui/field"; import { FormField } from "@/components/shared/form/FormField"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; diff --git a/ui/litellm-dashboard/src/app/login/LoginPage.tsx b/ui/litellm-dashboard/src/app/login/LoginPage.tsx index 3a1d120c8a5..11cf5ffad3d 100644 --- a/ui/litellm-dashboard/src/app/login/LoginPage.tsx +++ b/ui/litellm-dashboard/src/app/login/LoginPage.tsx @@ -6,7 +6,7 @@ import LoadingScreen from "@/components/common_components/LoadingScreen"; import { exchangeLoginCode, getProxyBaseUrl, switchToWorkerUrl } from "@/components/networking"; import { Alert, AlertAction, AlertDescription, AlertTitle } from "@/components/shared/Alert"; import { PasswordInput } from "@/components/shared/PasswordInput"; -import { Field, FieldGroup, FieldLabel } from "@/components/shared/form/field"; +import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"; import { FormField } from "@/components/shared/form/FormField"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; diff --git a/ui/litellm-dashboard/src/app/onboarding/OnboardingFormBody.tsx b/ui/litellm-dashboard/src/app/onboarding/OnboardingFormBody.tsx index 77fae186c90..96eb165d43c 100644 --- a/ui/litellm-dashboard/src/app/onboarding/OnboardingFormBody.tsx +++ b/ui/litellm-dashboard/src/app/onboarding/OnboardingFormBody.tsx @@ -3,7 +3,7 @@ import { CircleAlert, Info } from "lucide-react"; import { z } from "zod/v4"; import { Alert, AlertDescription, AlertTitle } from "@/components/shared/Alert"; import { PasswordInput } from "@/components/shared/PasswordInput"; -import { Field, FieldLabel, FieldGroup } from "@/components/shared/form/field"; +import { Field, FieldLabel, FieldGroup } from "@/components/ui/field"; import { FormField } from "@/components/shared/form/FormField"; import { Button, buttonVariants } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; diff --git a/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroCreateModal.tsx b/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroCreateModal.tsx index 5d89386493d..5b3c4b52b19 100644 --- a/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroCreateModal.tsx +++ b/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroCreateModal.tsx @@ -3,7 +3,7 @@ import { z } from "zod/v4"; import { useCloudZeroCreate } from "@/app/(dashboard)/hooks/cloudzero/useCloudZeroCreate"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; -import { FieldGroup } from "@/components/shared/form/field"; +import { FieldGroup } from "@/components/ui/field"; import { FormField } from "@/components/shared/form/FormField"; import { Input } from "@/components/ui/input"; import { TooltipProvider } from "@/components/ui/tooltip"; diff --git a/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroUpdateModal.tsx b/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroUpdateModal.tsx index ceee32df4aa..34cf1b87717 100644 --- a/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroUpdateModal.tsx +++ b/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroUpdateModal.tsx @@ -3,7 +3,7 @@ import { z } from "zod/v4"; import { useCloudZeroUpdateSettings } from "@/app/(dashboard)/hooks/cloudzero/useCloudZeroSettings"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; -import { FieldGroup } from "@/components/shared/form/field"; +import { FieldGroup } from "@/components/ui/field"; import { FormField } from "@/components/shared/form/FormField"; import { Input } from "@/components/ui/input"; import { TooltipProvider } from "@/components/ui/tooltip"; diff --git a/ui/litellm-dashboard/src/components/CreateUserButton.tsx b/ui/litellm-dashboard/src/components/CreateUserButton.tsx index 52db83b5dc6..e052a9e0818 100644 --- a/ui/litellm-dashboard/src/components/CreateUserButton.tsx +++ b/ui/litellm-dashboard/src/components/CreateUserButton.tsx @@ -1,7 +1,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations"; import { MultiSelect } from "@/components/shared/MultiSelect"; -import { FieldGroup } from "@/components/shared/form/field"; +import { FieldGroup } from "@/components/ui/field"; import { FormField } from "@/components/shared/form/FormField"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; diff --git a/ui/litellm-dashboard/src/components/SCIM.tsx b/ui/litellm-dashboard/src/components/SCIM.tsx index 190887352a5..12b47aeee6a 100644 --- a/ui/litellm-dashboard/src/components/SCIM.tsx +++ b/ui/litellm-dashboard/src/components/SCIM.tsx @@ -6,7 +6,7 @@ import { CircleAlert, CirclePlus, Copy, Info, KeyRound, Link } from "lucide-reac import { parseErrorMessage } from "./shared/errorUtils"; import { toast } from "@/lib/toast"; import { Alert, AlertDescription, AlertTitle } from "@/components/shared/Alert"; -import { FieldGroup } from "@/components/shared/form/field"; +import { FieldGroup } from "@/components/ui/field"; import { FormField } from "@/components/shared/form/FormField"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardTitle } from "@/components/ui/card"; diff --git a/ui/litellm-dashboard/src/components/SSOModals.tsx b/ui/litellm-dashboard/src/components/SSOModals.tsx index e20d6ab3093..609c0efcd99 100644 --- a/ui/litellm-dashboard/src/components/SSOModals.tsx +++ b/ui/litellm-dashboard/src/components/SSOModals.tsx @@ -4,7 +4,7 @@ import { getSSOSettings, updateSSOSettings } from "./networking"; import { toast } from "@/lib/toast"; import { parseErrorMessage } from "./shared/errorUtils"; import { Button } from "@/components/ui/button"; -import { FieldGroup } from "@/components/shared/form/field"; +import { FieldGroup } from "@/components/ui/field"; import { GroupClaimField, MappingToggleField, diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/HashicorpVault/EditHashicorpVaultModal.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/HashicorpVault/EditHashicorpVaultModal.tsx index 41d41e64da0..33aac24a1ba 100644 --- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/HashicorpVault/EditHashicorpVaultModal.tsx +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/HashicorpVault/EditHashicorpVaultModal.tsx @@ -6,7 +6,7 @@ import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import { toast } from "@/lib/toast"; import React, { useMemo } from "react"; import { z } from "zod/v4"; -import { FieldGroup } from "@/components/shared/form/field"; +import { FieldGroup } from "@/components/ui/field"; import { FormField } from "@/components/shared/form/FormField"; import { PasswordInput } from "@/components/shared/PasswordInput"; import { Button } from "@/components/ui/button"; diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/LoggingSettings/LoggingSettings.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/LoggingSettings/LoggingSettings.tsx index a9702451035..3ec18a537c9 100644 --- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/LoggingSettings/LoggingSettings.tsx +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/LoggingSettings/LoggingSettings.tsx @@ -12,7 +12,7 @@ import { } from "@/app/(dashboard)/hooks/storeRequestInSpendLogs/useStoreRequestInSpendLogs"; import { toast } from "@/lib/toast"; import { parseErrorMessage } from "@/components/shared/errorUtils"; -import { FieldGroup } from "@/components/shared/form/field"; +import { FieldGroup } from "@/components/ui/field"; import { FormField } from "@/components/shared/form/FormField"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/MCPSemanticFilterSettings/MCPSemanticFilterSettings.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/MCPSemanticFilterSettings/MCPSemanticFilterSettings.tsx index f7ec5238a9d..390684d3739 100644 --- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/MCPSemanticFilterSettings/MCPSemanticFilterSettings.tsx +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/MCPSemanticFilterSettings/MCPSemanticFilterSettings.tsx @@ -9,7 +9,7 @@ import { Alert, AlertAction, AlertDescription, AlertTitle } from "@/components/s import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import { fetchAvailableModels, ModelGroup } from "@/components/llm_calls/fetch_models"; -import { FieldGroup } from "@/components/shared/form/field"; +import { FieldGroup } from "@/components/ui/field"; import { FormField } from "@/components/shared/form/FormField"; import { SearchSelect } from "@/components/shared/SearchSelect"; import { Button } from "@/components/ui/button"; diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/PluginSettings/PluginSettings.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/PluginSettings/PluginSettings.tsx index 90a253b9ebe..e468fc2d91a 100644 --- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/PluginSettings/PluginSettings.tsx +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/PluginSettings/PluginSettings.tsx @@ -5,7 +5,7 @@ import { Button } from "@/components/ui/button"; import { Eye, EyeOff, Pencil, Plus, Trash2 } from "lucide-react"; import { getConfigFieldSetting, updateConfigFieldSetting } from "@/components/networking"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; -import { FieldGroup } from "@/components/shared/form/field"; +import { FieldGroup } from "@/components/ui/field"; import { FormField } from "@/components/shared/form/FormField"; import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/BaseSSOSettingsForm.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/BaseSSOSettingsForm.tsx index 26d0785529c..5216da382d0 100644 --- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/BaseSSOSettingsForm.tsx +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/BaseSSOSettingsForm.tsx @@ -5,7 +5,7 @@ import { FormProvider, useFormContext, useWatch, type UseFormReturn } from "reac import { z } from "zod/v4"; import { ssoProviderLogoMap, ssoProviderDisplayNames } from "../constants"; import { Logo } from "@/components/molecules/logo/Logo"; -import { FieldGroup } from "@/components/shared/form/field"; +import { FieldGroup } from "@/components/ui/field"; import { FormField } from "@/components/shared/form/FormField"; import { PasswordInput } from "@/components/shared/PasswordInput"; import { Checkbox } from "@/components/ui/checkbox"; diff --git a/ui/litellm-dashboard/src/components/Teams.tsx b/ui/litellm-dashboard/src/components/Teams.tsx index a93a8545375..e2eda4adb23 100644 --- a/ui/litellm-dashboard/src/components/Teams.tsx +++ b/ui/litellm-dashboard/src/components/Teams.tsx @@ -9,7 +9,7 @@ import { Input as UIInput } from "@/components/ui/input"; import { Switch } from "@/components/ui/switch"; import { Textarea } from "@/components/ui/textarea"; import { TooltipProvider } from "@/components/ui/tooltip"; -import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/shared/form/field"; +import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field"; import { FormField } from "@/components/shared/form/FormField"; import { SearchSelect } from "@/components/shared/SearchSelect"; import { labelWithDocsHint, labelWithHint } from "@/components/shared/form/LabelWithHint"; diff --git a/ui/litellm-dashboard/src/components/UIAccessControlForm.tsx b/ui/litellm-dashboard/src/components/UIAccessControlForm.tsx index 8d85d46d011..ed502e8d761 100644 --- a/ui/litellm-dashboard/src/components/UIAccessControlForm.tsx +++ b/ui/litellm-dashboard/src/components/UIAccessControlForm.tsx @@ -3,7 +3,7 @@ import React, { useEffect, useState } from "react"; import { useWatch } from "react-hook-form"; import { z } from "zod/v4"; -import { FieldGroup } from "@/components/shared/form/field"; +import { FieldGroup } from "@/components/ui/field"; import { FormField } from "@/components/shared/form/FormField"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; diff --git a/ui/litellm-dashboard/src/components/add_model/AddModelForm.tsx b/ui/litellm-dashboard/src/components/add_model/AddModelForm.tsx index 7e084a532f4..b6884327a3c 100644 --- a/ui/litellm-dashboard/src/components/add_model/AddModelForm.tsx +++ b/ui/litellm-dashboard/src/components/add_model/AddModelForm.tsx @@ -4,7 +4,7 @@ import { useTags } from "@/app/(dashboard)/hooks/tags/useTags"; import { all_admin_roles, isUserTeamAdminForAnyTeam } from "@/utils/roles"; import { modelCreationScope } from "@/utils/modelPermissions"; import { Switch } from "@/components/ui/switch"; -import { Field, FieldLabel } from "@/components/shared/form/field"; +import { Field, FieldLabel } from "@/components/ui/field"; import { Card, CardContent } from "@/components/ui/card"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { SearchSelect, type SearchSelectOption } from "@/components/shared/SearchSelect"; diff --git a/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.tsx b/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.tsx index 46ab0bc2c9f..9be5391040a 100644 --- a/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.tsx +++ b/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.tsx @@ -3,7 +3,7 @@ import { useQuery } from "@tanstack/react-query"; import { useWatch } from "react-hook-form"; import { ChevronDown, ChevronRight, CircleHelp } from "lucide-react"; import { z } from "zod/v4"; -import { FieldGroup } from "@/components/shared/form/field"; +import { FieldGroup } from "@/components/ui/field"; import { FormField } from "@/components/shared/form/FormField"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; diff --git a/ui/litellm-dashboard/src/components/cloudzero_export_modal.tsx b/ui/litellm-dashboard/src/components/cloudzero_export_modal.tsx index 81d62fc398e..91ba75552dc 100644 --- a/ui/litellm-dashboard/src/components/cloudzero_export_modal.tsx +++ b/ui/litellm-dashboard/src/components/cloudzero_export_modal.tsx @@ -5,7 +5,7 @@ import { getGlobalLitellmHeaderName } from "@/components/networking"; import { toast } from "@/lib/toast"; import { Alert, AlertDescription, AlertTitle } from "@/components/shared/Alert"; import { PasswordInput } from "@/components/shared/PasswordInput"; -import { FieldGroup } from "@/components/shared/form/field"; +import { FieldGroup } from "@/components/ui/field"; import { FormField } from "@/components/shared/form/FormField"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; diff --git a/ui/litellm-dashboard/src/components/common_components/MountedFormField.tsx b/ui/litellm-dashboard/src/components/common_components/MountedFormField.tsx index f0efa885d92..65f603b7efd 100644 --- a/ui/litellm-dashboard/src/components/common_components/MountedFormField.tsx +++ b/ui/litellm-dashboard/src/components/common_components/MountedFormField.tsx @@ -9,7 +9,7 @@ import { type UseFormGetValues, } from "react-hook-form"; -import { Field, FieldDescription, FieldError, FieldLabel } from "@/components/shared/form/field"; +import { Field, FieldDescription, FieldError, FieldLabel } from "@/components/ui/field"; export type MountedFormValues = Record; diff --git a/ui/litellm-dashboard/src/components/common_components/PassThroughGuardrailsSection.tsx b/ui/litellm-dashboard/src/components/common_components/PassThroughGuardrailsSection.tsx index c210ab3f9fd..a98dca885ca 100644 --- a/ui/litellm-dashboard/src/components/common_components/PassThroughGuardrailsSection.tsx +++ b/ui/litellm-dashboard/src/components/common_components/PassThroughGuardrailsSection.tsx @@ -4,7 +4,7 @@ import { Alert, AlertDescription, AlertTitle } from "@/components/shared/Alert"; import GuardrailSelector from "../guardrails/GuardrailSelector"; import { TagsInput } from "@/app/(dashboard)/guardrails/_components/content_filter/TagsInput"; -import { Field, FieldLabel } from "@/components/shared/form/field"; +import { Field, FieldLabel } from "@/components/ui/field"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; diff --git a/ui/litellm-dashboard/src/components/common_components/user_search_modal.tsx b/ui/litellm-dashboard/src/components/common_components/user_search_modal.tsx index 2977a9e4941..3e2d3693d4f 100644 --- a/ui/litellm-dashboard/src/components/common_components/user_search_modal.tsx +++ b/ui/litellm-dashboard/src/components/common_components/user_search_modal.tsx @@ -5,7 +5,7 @@ import { useDebouncedCallback } from "@tanstack/react-pacer/debouncer"; import { useForm } from "react-hook-form"; import { userFilterUICall } from "@/components/networking"; import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants"; -import { FieldGroup } from "@/components/shared/form/field"; +import { FieldGroup } from "@/components/ui/field"; import { FormField } from "@/components/shared/form/FormField"; import { Button } from "@/components/ui/button"; import { diff --git a/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.tsx b/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.tsx index 811ff69642a..8e5317d9c1f 100644 --- a/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.tsx +++ b/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useMemo, useState } from "react"; import { z } from "zod/v4"; import { toast } from "@/lib/toast"; import { CircleHelp } from "lucide-react"; -import { FieldGroup } from "@/components/shared/form/field"; +import { FieldGroup } from "@/components/ui/field"; import { FormField } from "@/components/shared/form/FormField"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; diff --git a/ui/litellm-dashboard/src/components/key_team_helpers/ModelMaxBudgetEditor.tsx b/ui/litellm-dashboard/src/components/key_team_helpers/ModelMaxBudgetEditor.tsx index c6e6a1a1495..ef5a3e15b52 100644 --- a/ui/litellm-dashboard/src/components/key_team_helpers/ModelMaxBudgetEditor.tsx +++ b/ui/litellm-dashboard/src/components/key_team_helpers/ModelMaxBudgetEditor.tsx @@ -1,5 +1,5 @@ import { SearchSelect } from "@/components/shared/SearchSelect"; -import { Field, FieldLabel } from "@/components/shared/form/field"; +import { Field, FieldLabel } from "@/components/ui/field"; import { Button } from "@/components/ui/button"; import { InputGroup, InputGroupAddon, InputGroupInput, InputGroupText } from "@/components/ui/input-group"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; diff --git a/ui/litellm-dashboard/src/components/mcp_tools/MCPToolArgumentsForm.tsx b/ui/litellm-dashboard/src/components/mcp_tools/MCPToolArgumentsForm.tsx index f0b211aecb3..ca8c5697e6e 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/MCPToolArgumentsForm.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/MCPToolArgumentsForm.tsx @@ -1,7 +1,7 @@ import React, { forwardRef, useImperativeHandle, useMemo } from "react"; import { CircleHelp } from "lucide-react"; import { useForm, type Resolver } from "react-hook-form"; -import { FieldGroup } from "@/components/shared/form/field"; +import { FieldGroup } from "@/components/ui/field"; import { FormField } from "@/components/shared/form/FormField"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; diff --git a/ui/litellm-dashboard/src/components/model_add/reuse_credentials.tsx b/ui/litellm-dashboard/src/components/model_add/reuse_credentials.tsx index f67413ebee7..772e52dd0a5 100644 --- a/ui/litellm-dashboard/src/components/model_add/reuse_credentials.tsx +++ b/ui/litellm-dashboard/src/components/model_add/reuse_credentials.tsx @@ -1,6 +1,6 @@ import React from "react"; import { z } from "zod/v4"; -import { Field, FieldGroup, FieldLabel } from "@/components/shared/form/field"; +import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"; import { FormField } from "@/components/shared/form/FormField"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; diff --git a/ui/litellm-dashboard/src/components/model_dashboard/ModelSettingsModal/ModelSettingsModal.tsx b/ui/litellm-dashboard/src/components/model_dashboard/ModelSettingsModal/ModelSettingsModal.tsx index 65f607a897f..699d77ccc47 100644 --- a/ui/litellm-dashboard/src/components/model_dashboard/ModelSettingsModal/ModelSettingsModal.tsx +++ b/ui/litellm-dashboard/src/components/model_dashboard/ModelSettingsModal/ModelSettingsModal.tsx @@ -4,7 +4,7 @@ import { ConfigType, useProxyConfig } from "@/app/(dashboard)/hooks/proxyConfig/ import { StoreModelInDBParams, useStoreModelInDB } from "@/app/(dashboard)/hooks/storeModelInDB/useStoreModelInDB"; import { toast } from "@/lib/toast"; import { parseErrorMessage } from "@/components/shared/errorUtils"; -import { FieldGroup } from "@/components/shared/form/field"; +import { FieldGroup } from "@/components/ui/field"; import { FormField } from "@/components/shared/form/FormField"; import { Switch } from "@/components/ui/switch"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; diff --git a/ui/litellm-dashboard/src/components/organisms/RegenerateKeyModal.tsx b/ui/litellm-dashboard/src/components/organisms/RegenerateKeyModal.tsx index a2658985f32..84724d01a1d 100644 --- a/ui/litellm-dashboard/src/components/organisms/RegenerateKeyModal.tsx +++ b/ui/litellm-dashboard/src/components/organisms/RegenerateKeyModal.tsx @@ -10,7 +10,7 @@ import { z } from "zod/v4"; import { KeyResponse } from "../key_team_helpers/key_list"; import { toast } from "@/lib/toast"; import { regenerateKeyCall } from "../networking"; -import { FieldGroup } from "@/components/shared/form/field"; +import { FieldGroup } from "@/components/ui/field"; import { FormField } from "@/components/shared/form/FormField"; import { Input } from "@/components/ui/input"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; diff --git a/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx b/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx index 011cdf33f3a..e1e6dcfa442 100644 --- a/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx +++ b/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx @@ -11,7 +11,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { Button } from "@/components/ui/button"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; import { Input } from "@/components/ui/input"; -import { Field, FieldLabel } from "@/components/shared/form/field"; +import { Field, FieldLabel } from "@/components/ui/field"; import { Badge } from "@/components/ui/badge"; import { Combobox, diff --git a/ui/litellm-dashboard/src/components/organization/org-create/OrgCreateDialog.tsx b/ui/litellm-dashboard/src/components/organization/org-create/OrgCreateDialog.tsx index e9d9ba0dcf8..93d552bebe0 100644 --- a/ui/litellm-dashboard/src/components/organization/org-create/OrgCreateDialog.tsx +++ b/ui/litellm-dashboard/src/components/organization/org-create/OrgCreateDialog.tsx @@ -7,7 +7,7 @@ import { organizationKeys } from "@/app/(dashboard)/hooks/organizations/useOrgan import { ModelSelect } from "@/components/ModelSelect/ModelSelect"; import MCPServerSelector from "@/components/mcp_server_management/MCPServerSelector"; import { toast } from "@/lib/toast"; -import { FieldGroup } from "@/components/shared/form/field"; +import { FieldGroup } from "@/components/ui/field"; import { FormField } from "@/components/shared/form/FormField"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; diff --git a/ui/litellm-dashboard/src/components/organization/org-settings/OrgSettingsForm.tsx b/ui/litellm-dashboard/src/components/organization/org-settings/OrgSettingsForm.tsx index a1b42493b77..926b8943a79 100644 --- a/ui/litellm-dashboard/src/components/organization/org-settings/OrgSettingsForm.tsx +++ b/ui/litellm-dashboard/src/components/organization/org-settings/OrgSettingsForm.tsx @@ -8,7 +8,7 @@ import { ModelSelect } from "@/components/ModelSelect/ModelSelect"; import MCPServerSelector from "@/components/mcp_server_management/MCPServerSelector"; import { toast } from "@/lib/toast"; import type { Organization } from "@/components/networking"; -import { FieldGroup } from "@/components/shared/form/field"; +import { FieldGroup } from "@/components/ui/field"; import { FormField } from "@/components/shared/form/FormField"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; diff --git a/ui/litellm-dashboard/src/components/routing_groups/RoutingGroupModal.tsx b/ui/litellm-dashboard/src/components/routing_groups/RoutingGroupModal.tsx index 8b7361fb572..442275c32f6 100644 --- a/ui/litellm-dashboard/src/components/routing_groups/RoutingGroupModal.tsx +++ b/ui/litellm-dashboard/src/components/routing_groups/RoutingGroupModal.tsx @@ -3,7 +3,7 @@ import React, { useEffect, useMemo } from "react"; import { useWatch } from "react-hook-form"; import { z } from "zod/v4"; -import { FieldGroup } from "@/components/shared/form/field"; +import { FieldGroup } from "@/components/ui/field"; import { FormField } from "@/components/shared/form/FormField"; import { Combobox, diff --git a/ui/litellm-dashboard/src/components/settings.tsx b/ui/litellm-dashboard/src/components/settings.tsx index 1b1bad88d51..e6337cef9bd 100644 --- a/ui/litellm-dashboard/src/components/settings.tsx +++ b/ui/litellm-dashboard/src/components/settings.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useState } from "react"; import { Controller, FormProvider, useForm, useFormContext } from "react-hook-form"; -import { Field, FieldError, FieldLabel } from "@/components/shared/form/field"; +import { Field, FieldError, FieldLabel } from "@/components/ui/field"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; import { diff --git a/ui/litellm-dashboard/src/components/shared/form/FormField.tsx b/ui/litellm-dashboard/src/components/shared/form/FormField.tsx index 3b9783333cc..cb8ef5375fd 100644 --- a/ui/litellm-dashboard/src/components/shared/form/FormField.tsx +++ b/ui/litellm-dashboard/src/components/shared/form/FormField.tsx @@ -9,7 +9,7 @@ import { type FieldValues, } from "react-hook-form"; -import { Field, FieldDescription, FieldError, FieldLabel } from "./field"; +import { Field, FieldDescription, FieldError, FieldLabel } from "@/components/ui/field"; export type FormFieldControlProps< TFieldValues extends FieldValues, diff --git a/ui/litellm-dashboard/src/components/team/EditMembership.tsx b/ui/litellm-dashboard/src/components/team/EditMembership.tsx index 96ebb5fe0fd..036c4f1cc0b 100644 --- a/ui/litellm-dashboard/src/components/team/EditMembership.tsx +++ b/ui/litellm-dashboard/src/components/team/EditMembership.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useMemo, useState } from "react"; import { z } from "zod/v4"; import NumericalInput from "../shared/numerical_input"; import BudgetDurationDropdown from "../common_components/budget_duration_dropdown"; -import { FieldGroup } from "@/components/shared/form/field"; +import { FieldGroup } from "@/components/ui/field"; import { FormField } from "@/components/shared/form/FormField"; import { MultiSelect } from "@/components/shared/MultiSelect"; import { Button } from "@/components/ui/button"; diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx index 88a893fd3e4..5750add95e3 100644 --- a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx @@ -31,7 +31,7 @@ import { Switch } from "@/components/ui/switch"; import { Textarea } from "@/components/ui/textarea"; import { SimpleTooltip, TooltipProvider } from "@/components/ui/tooltip"; import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner"; -import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/shared/form/field"; +import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field"; import { FormField } from "@/components/shared/form/FormField"; import { labelWithDocsHint, labelWithHint } from "@/components/shared/form/LabelWithHint"; import { MultiSelect } from "@/components/shared/MultiSelect"; diff --git a/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx b/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx index 59d003690d3..8f3f6e19ae8 100644 --- a/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx @@ -10,7 +10,7 @@ import { Switch } from "@/components/ui/switch"; import { Textarea } from "@/components/ui/textarea"; import { TooltipProvider } from "@/components/ui/tooltip"; import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner"; -import { Field, FieldGroup, FieldLabel } from "@/components/shared/form/field"; +import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"; import { FormField } from "@/components/shared/form/FormField"; import React, { useEffect, useRef, useState } from "react"; import { hasCapability } from "../../utils/capabilities"; diff --git a/ui/litellm-dashboard/src/components/shared/form/field.test.tsx b/ui/litellm-dashboard/src/components/ui/field.test.tsx similarity index 79% rename from ui/litellm-dashboard/src/components/shared/form/field.test.tsx rename to ui/litellm-dashboard/src/components/ui/field.test.tsx index 54b589ce2f4..4bb3d0ec665 100644 --- a/ui/litellm-dashboard/src/components/shared/form/field.test.tsx +++ b/ui/litellm-dashboard/src/components/ui/field.test.tsx @@ -123,3 +123,38 @@ describe("field primitives forward refs to their DOM node", () => { expect(ref.current).toBeInstanceOf(HTMLDivElement); }); }); + +describe("FieldLabel wrapping a nested Field", () => { + const NESTED_FIELD_CUES = [ + "has-[>[data-slot=field]]:not-has-[:disabled,[data-disabled]]:hover:bg-muted/50", + "has-[>[data-slot=field]]:has-[:focus-visible]:border-ring", + "has-[>[data-slot=field]]:has-[:focus-visible]:ring-3", + "has-[>[data-slot=field]]:has-[:focus-visible]:ring-ring/50", + ]; + + it("carries the hover, focus-visible and disabled cues the card layout depends on", () => { + render( + + + + + , + ); + + expect(screen.getByTestId("card-label")).toHaveClass(...NESTED_FIELD_CUES); + }); + + it("keeps the cues addressable when a caller passes its own className", () => { + render( + + + + + , + ); + const label = screen.getByTestId("card-label"); + + expect(label).toHaveClass(...NESTED_FIELD_CUES); + expect(label).toHaveClass("mt-2"); + }); +}); diff --git a/ui/litellm-dashboard/src/components/shared/form/field.tsx b/ui/litellm-dashboard/src/components/ui/field.tsx similarity index 59% rename from ui/litellm-dashboard/src/components/shared/form/field.tsx rename to ui/litellm-dashboard/src/components/ui/field.tsx index a5bf896313a..ff9f73ceecf 100644 --- a/ui/litellm-dashboard/src/components/shared/form/field.tsx +++ b/ui/litellm-dashboard/src/components/ui/field.tsx @@ -1,17 +1,15 @@ "use client"; -import * as React from "react"; - -import { Label } from "@/components/ui/label"; -import { Separator } from "@/components/ui/separator"; +import { useMemo } from "react"; import { cva, type VariantProps } from "class-variance-authority"; import { cn } from "@/lib/cva.config"; +import { Label } from "@/components/ui/label"; +import { Separator } from "@/components/ui/separator"; -const FieldSet = React.forwardRef>( - ({ className, ...props }, ref) => ( +function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) { + return (
[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3", @@ -19,28 +17,27 @@ const FieldSet = React.forwardRef - ), -); -FieldSet.displayName = "FieldSet"; + ); +} -const FieldLegend = React.forwardRef< - HTMLLegendElement, - React.ComponentPropsWithoutRef<"legend"> & { variant?: "legend" | "label" } ->(({ className, variant = "legend", ...props }, ref) => ( - -)); -FieldLegend.displayName = "FieldLegend"; +function FieldLegend({ + className, + variant = "legend", + ...props +}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) { + return ( + + ); +} -const FieldGroup = React.forwardRef>( - ({ className, ...props }, ref) => ( +function FieldGroup({ className, ...props }: React.ComponentProps<"div">) { + return (
- ), -); -FieldGroup.displayName = "FieldGroup"; + ); +} const fieldVariants = cva("group/field flex w-full gap-3 data-[invalid=true]:text-destructive", { variants: { @@ -67,53 +63,49 @@ const fieldVariants = cva("group/field flex w-full gap-3 data-[invalid=true]:tex }, }); -const Field = React.forwardRef< - HTMLDivElement, - React.ComponentPropsWithoutRef<"div"> & VariantProps ->(({ className, orientation = "vertical", ...props }, ref) => ( -
-)); -Field.displayName = "Field"; - -const FieldContent = React.forwardRef>( - ({ className, ...props }, ref) => ( +function Field({ + className, + orientation = "vertical", + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ); +} + +function FieldContent({ className, ...props }: React.ComponentProps<"div">) { + return (
- ), -); -FieldContent.displayName = "FieldContent"; + ); +} -const FieldLabel = React.forwardRef>( - ({ className, ...props }, ref) => ( +function FieldLabel({ className, ...props }: React.ComponentProps) { + return (