diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 698c1e6e752..050b0a7e60c 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -2729,6 +2729,58 @@ 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: 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) + ) + + 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. @@ -2814,46 +2866,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 = modified_content.replace( - "/litellm/.well-known/litellm-ui-config", - f"{server_root_path}/.well-known/litellm-ui-config", - ) - - 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/test_proxy_server.py b/tests/unit/proxy/test_proxy_server.py index a50db09128f..dd638618ddf 100644 --- a/tests/unit/proxy/test_proxy_server.py +++ b/tests/unit/proxy/test_proxy_server.py @@ -3246,6 +3246,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): """