From aeb333273d554c823214d3f6494db03fe0d1c113 Mon Sep 17 00:00:00 2001 From: shehryars715 Date: Mon, 5 Oct 2026 00:39:31 +0500 Subject: [PATCH 1/4] fix(proxy): stop prepending SERVER_ROOT_PATH to the UI config path on every restart The proxy rewrites the packaged UI files in place on each start. For a SERVER_ROOT_PATH ending in /litellm (e.g. /foo/litellm), the rewritten config path still contains the original one, so every restart prepended the root path again (/foo/foo/litellm/.well-known/litellm-ui-config), the request 404'd and the UI stayed on "Loading...". Leave already-rewritten paths alone so the rewrite is idempotent. Fixes #44517 --- litellm/proxy/proxy_server.py | 19 +++++++++++---- .../proxy/proxy_server/test_ui_config_path.py | 24 +++++++++++++++++++ 2 files changed, 39 insertions(+), 4 deletions(-) create mode 100644 tests/unit/proxy/proxy_server/test_ui_config_path.py diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 089647c6297..e1a522f0621 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -2365,6 +2365,20 @@ try: except (PermissionError, OSError) as e: return False, str(e) + def _rewrite_ui_config_path(content: str, server_root_path: str) -> str: + """ + Point the UI's config request at server_root_path, keeping already-rewritten paths. + + The UI files are rewritten in place on every startup, and when server_root_path ends + in /litellm the rewritten path still contains the original one, so a plain replace + would prepend the root path again on each restart. + """ + ui_config_path = f"{server_root_path}/.well-known/litellm-ui-config" + return ui_config_path.join( + part.replace("/litellm/.well-known/litellm-ui-config", ui_config_path) + for part in content.split(ui_config_path) + ) + # Use a writable runtime UI directory whenever possible. # This prevents mutating the packaged UI directory (e.g. site-packages or the repo checkout) # and ensures extensionless routes like /ui/login work via /index.html. @@ -2480,10 +2494,7 @@ try: ) # Replace the /.well-known/litellm-ui-config with the server root path - modified_content = modified_content.replace( - "/litellm/.well-known/litellm-ui-config", - f"{server_root_path}/.well-known/litellm-ui-config", - ) + modified_content = _rewrite_ui_config_path(modified_content, server_root_path) with open(file_path, "w", encoding="utf-8") as f: f.write(modified_content) diff --git a/tests/unit/proxy/proxy_server/test_ui_config_path.py b/tests/unit/proxy/proxy_server/test_ui_config_path.py new file mode 100644 index 00000000000..934f71ed97b --- /dev/null +++ b/tests/unit/proxy/proxy_server/test_ui_config_path.py @@ -0,0 +1,24 @@ +"""SERVER_ROOT_PATH rewrite of the UI's config request (#44517).""" + +from __future__ import annotations + +import pytest + +from litellm.proxy.proxy_server import _rewrite_ui_config_path + +# The two shapes the config path takes in the packaged UI bundle +PACKAGED_UI_CHUNK = ( + 'fetch(`${g}/litellm/.well-known/litellm-ui-config`);const u={url:"/litellm/.well-known/litellm-ui-config"}' +) + + +@pytest.mark.parametrize("server_root_path", ["/foo", "/litellm", "/foo/litellm"]) +def test_restarts_keep_a_single_root_path_prefix(server_root_path: str) -> None: + rewritten = PACKAGED_UI_CHUNK + for _ in range(3): # the proxy rewrites the same UI files on every start + rewritten = _rewrite_ui_config_path(rewritten, server_root_path) + + assert rewritten == ( + f"fetch(`${{g}}{server_root_path}/.well-known/litellm-ui-config`);" + f'const u={{url:"{server_root_path}/.well-known/litellm-ui-config"}}' + ) From 2ac43122bdf75609fde6a3bc6005674197a05a20 Mon Sep 17 00:00:00 2001 From: shehryars715 Date: Mon, 5 Oct 2026 00:59:24 +0500 Subject: [PATCH 2/4] test(proxy): run the #44517 regression test through the startup UI rewrite Move the startup loop that rewrites the UI files into _apply_server_root_path_to_ui so the test can run it on a temp UI directory across restarts, and fold the test into test_proxy_server.py --- litellm/proxy/proxy_server.py | 76 ++++++++++--------- .../proxy/proxy_server/test_ui_config_path.py | 24 ------ tests/unit/proxy/test_proxy_server.py | 20 +++++ 3 files changed, 59 insertions(+), 61 deletions(-) delete mode 100644 tests/unit/proxy/proxy_server/test_ui_config_path.py diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index e1a522f0621..2f9aab2ad10 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -2379,6 +2379,44 @@ try: for part in content.split(ui_config_path) ) + def _apply_server_root_path_to_ui(ui_dir: str, server_root_path: str) -> None: + for root, dirs, files in os.walk(ui_dir): + for filename in files: + file_path = os.path.join(root, filename) + # Skip binary files and files that don't need path replacement + if filename.endswith( + ( + ".png", + ".jpg", + ".jpeg", + ".gif", + ".ico", + ".woff", + ".woff2", + ".ttf", + ".eot", + ) + ): + continue + try: + with open(file_path, "r", encoding="utf-8") as f: + content = f.read() + + # Replace the asset prefix with the server root path + modified_content = content.replace( + f"{litellm_asset_prefix}", + f"{server_root_path}", + ) + + # Replace the /.well-known/litellm-ui-config with the server root path + modified_content = _rewrite_ui_config_path(modified_content, server_root_path) + + with open(file_path, "w", encoding="utf-8") as f: + f.write(modified_content) + except (UnicodeDecodeError, PermissionError, OSError): + # Skip binary files or files we can't write to + continue + # Use a writable runtime UI directory whenever possible. # This prevents mutating the packaged UI directory (e.g. site-packages or the repo checkout) # and ensures extensionless routes like /ui/login work via /index.html. @@ -2464,43 +2502,7 @@ try: ui_path, ) else: - # Iterate through files in the UI directory - for root, dirs, files in os.walk(ui_path): - for filename in files: - file_path = os.path.join(root, filename) - # Skip binary files and files that don't need path replacement - if filename.endswith( - ( - ".png", - ".jpg", - ".jpeg", - ".gif", - ".ico", - ".woff", - ".woff2", - ".ttf", - ".eot", - ) - ): - continue - try: - with open(file_path, "r", encoding="utf-8") as f: - content = f.read() - - # Replace the asset prefix with the server root path - modified_content = content.replace( - f"{litellm_asset_prefix}", - f"{server_root_path}", - ) - - # Replace the /.well-known/litellm-ui-config with the server root path - modified_content = _rewrite_ui_config_path(modified_content, server_root_path) - - with open(file_path, "w", encoding="utf-8") as f: - f.write(modified_content) - except (UnicodeDecodeError, PermissionError, OSError): - # Skip binary files or files we can't write to - continue + _apply_server_root_path_to_ui(ui_path, server_root_path) # # Mount the _next directory at the root level app.mount( diff --git a/tests/unit/proxy/proxy_server/test_ui_config_path.py b/tests/unit/proxy/proxy_server/test_ui_config_path.py deleted file mode 100644 index 934f71ed97b..00000000000 --- a/tests/unit/proxy/proxy_server/test_ui_config_path.py +++ /dev/null @@ -1,24 +0,0 @@ -"""SERVER_ROOT_PATH rewrite of the UI's config request (#44517).""" - -from __future__ import annotations - -import pytest - -from litellm.proxy.proxy_server import _rewrite_ui_config_path - -# The two shapes the config path takes in the packaged UI bundle -PACKAGED_UI_CHUNK = ( - 'fetch(`${g}/litellm/.well-known/litellm-ui-config`);const u={url:"/litellm/.well-known/litellm-ui-config"}' -) - - -@pytest.mark.parametrize("server_root_path", ["/foo", "/litellm", "/foo/litellm"]) -def test_restarts_keep_a_single_root_path_prefix(server_root_path: str) -> None: - rewritten = PACKAGED_UI_CHUNK - for _ in range(3): # the proxy rewrites the same UI files on every start - rewritten = _rewrite_ui_config_path(rewritten, server_root_path) - - assert rewritten == ( - f"fetch(`${{g}}{server_root_path}/.well-known/litellm-ui-config`);" - f'const u={{url:"{server_root_path}/.well-known/litellm-ui-config"}}' - ) diff --git a/tests/unit/proxy/test_proxy_server.py b/tests/unit/proxy/test_proxy_server.py index 79f4edd4d62..d9c257b6128 100644 --- a/tests/unit/proxy/test_proxy_server.py +++ b/tests/unit/proxy/test_proxy_server.py @@ -3020,6 +3020,26 @@ def test_non_root_ui_path_logic(monkeypatch, tmp_path, ui_exists, ui_has_content assert mock_logger.info.call_count == 0 +@pytest.mark.parametrize("server_root_path", ["/foo", "/litellm", "/foo/litellm"]) +def test_server_root_path_ui_rewrite_is_stable_across_restarts(tmp_path, server_root_path): + from litellm.proxy.proxy_server import _apply_server_root_path_to_ui + + chunk = tmp_path / "_next" / "static" / "chunks" / "app.js" + chunk.parent.mkdir(parents=True) + chunk.write_text( + 'fetch(`${g}/litellm/.well-known/litellm-ui-config`);const u={url:"/litellm/.well-known/litellm-ui-config"}', + encoding="utf-8", + ) + + for _ in range(3): + _apply_server_root_path_to_ui(str(tmp_path), server_root_path) + + assert chunk.read_text(encoding="utf-8") == ( + f"fetch(`${{g}}{server_root_path}/.well-known/litellm-ui-config`);" + f'const u={{url:"{server_root_path}/.well-known/litellm-ui-config"}}' + ) + + @pytest.mark.asyncio async def test_get_config_callbacks_with_all_types(client_no_auth): """ From 7f7111a2fa1c3874b4bc69ad77e86c39dd950372 Mon Sep 17 00:00:00 2001 From: shehryars715 Date: Sat, 10 Oct 2026 03:13:37 +0500 Subject: [PATCH 3/4] chore(proxy): declare ui_config_path Final for the type-discipline gate The LIT010 gate added on main requires new local bindings to be declared Final. ui_config_path is assigned once and never rebound. --- litellm/proxy/proxy_server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 2f9aab2ad10..9ce084fe7df 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -2373,7 +2373,7 @@ try: in /litellm the rewritten path still contains the original one, so a plain replace would prepend the root path again on each restart. """ - ui_config_path = f"{server_root_path}/.well-known/litellm-ui-config" + ui_config_path: Final = f"{server_root_path}/.well-known/litellm-ui-config" return ui_config_path.join( part.replace("/litellm/.well-known/litellm-ui-config", ui_config_path) for part in content.split(ui_config_path) From 6f2c2b22b83080ddea2947687dee1aca96559df2 Mon Sep 17 00:00:00 2001 From: shehryars715 Date: Mon, 12 Oct 2026 00:01:19 +0500 Subject: [PATCH 4/4] chore(proxy): mark the unused os.walk dirs name in the UI rewrite helper Moving the loop into _apply_server_root_path_to_ui made dirs a function local, which basedpyright reports as reportUnusedVariable. --- litellm/proxy/proxy_server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 9ce084fe7df..15d4fe1ca06 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -2380,7 +2380,7 @@ try: ) def _apply_server_root_path_to_ui(ui_dir: str, server_root_path: str) -> None: - for root, dirs, files in os.walk(ui_dir): + for root, _dirs, files in os.walk(ui_dir): for filename in files: file_path = os.path.join(root, filename) # Skip binary files and files that don't need path replacement