diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 37f3d6e49e0..4a39213166b 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -1463,7 +1463,30 @@ def _get_cors_config( return computed_origins, computed_credentials +def _get_cors_expose_headers( + expose_headers_env: str | None = None, +) -> list[str]: + """ + Compute the CORS Access-Control-Expose-Headers value. + + Defaults to LITELLM_UI_ALLOW_HEADERS. When LITELLM_CORS_EXPOSE_HEADERS is set + (comma-separated, parsed like LITELLM_CORS_ORIGINS), its entries are appended to + the defaults (de-duplicated, order preserved) so browser clients can read + additional x-litellm-* response headers. Unset leaves the default behaviour. + + Args: + expose_headers_env: Value of LITELLM_CORS_EXPOSE_HEADERS (defaults to os.getenv). + + Returns: + List[str]: headers to expose via Access-Control-Expose-Headers. + """ + _raw = expose_headers_env if expose_headers_env is not None else os.getenv("LITELLM_CORS_EXPOSE_HEADERS") + extra = [h.strip() for h in _raw.split(",") if h.strip()] if _raw else [] + return list(dict.fromkeys([*LITELLM_UI_ALLOW_HEADERS, *extra])) + + origins, allow_cors_credentials = _get_cors_config() +cors_expose_headers = _get_cors_expose_headers() # get current directory @@ -1776,7 +1799,7 @@ app.add_middleware( allow_credentials=allow_cors_credentials, allow_methods=["*"], allow_headers=["*"], - expose_headers=LITELLM_UI_ALLOW_HEADERS, + expose_headers=cors_expose_headers, ) app.add_middleware(PrometheusAuthMiddleware) diff --git a/tests/test_litellm/proxy/test_cors_config.py b/tests/test_litellm/proxy/test_cors_config.py index c654d266b74..0666dce73d0 100644 --- a/tests/test_litellm/proxy/test_cors_config.py +++ b/tests/test_litellm/proxy/test_cors_config.py @@ -123,6 +123,42 @@ def test_cors_explicit_credentials_case_insensitive(): assert allow_false is False +def test_cors_expose_headers_defaults_to_ui_allow_headers(): + """should return exactly LITELLM_UI_ALLOW_HEADERS when the env var is unset/empty.""" + from litellm.constants import LITELLM_UI_ALLOW_HEADERS + from litellm.proxy.proxy_server import _get_cors_expose_headers + + for empty in ("", " ", "\t"): + headers = _get_cors_expose_headers(expose_headers_env=empty) + assert headers == list(LITELLM_UI_ALLOW_HEADERS), f"Unexpected headers for {repr(empty)}" + + +def test_cors_expose_headers_appends_extra_headers(): + """should append configured headers to the defaults, parsed like LITELLM_CORS_ORIGINS.""" + from litellm.constants import LITELLM_UI_ALLOW_HEADERS + from litellm.proxy.proxy_server import _get_cors_expose_headers + + headers = _get_cors_expose_headers( + expose_headers_env=" x-litellm-response-cost , x-litellm-model-api-base ,," + ) + assert headers == [ + *LITELLM_UI_ALLOW_HEADERS, + "x-litellm-response-cost", + "x-litellm-model-api-base", + ] + + +def test_cors_expose_headers_dedupes_preserving_order(): + """should not duplicate a header already present in the defaults.""" + from litellm.constants import LITELLM_UI_ALLOW_HEADERS + from litellm.proxy.proxy_server import _get_cors_expose_headers + + dup = LITELLM_UI_ALLOW_HEADERS[0] + headers = _get_cors_expose_headers(expose_headers_env=f"{dup}, x-litellm-response-cost") + assert headers == [*LITELLM_UI_ALLOW_HEADERS, "x-litellm-response-cost"] + assert headers.count(dup) == 1 + + def test_proxy_server_cors_invariant(): """should verify that proxy_server module-level origins and allow_cors_credentials are consistent — catches any future drift in the module-level call to _get_cors_config.