Repository: skypilot-org/skypilot (python)

Issue:
[Docker] SkyPilot nightly image builds lag by a day
Our [nightly docker images](https://hub.docker.com/r/berkeleyskypilot/skypilot-nightly/tags) are lagging by a day.

* `berkeleyskypilot/skypilot-nightly:1.0.0.dev20250522` was published on 2025-05-23 @ 4:27a PT.

PyPI releases are ok:

* `skypilot-nightly==1.0.0.dev20250523` was published on 2025-05-23, 4:40a PT.

The issue lies in the publish time - our docker image builds from the latest nightly release on pypi, but the pypi build has a lag after it's published.

We should delay the docker build to account for this lag. 

Pull request description:
Refactor nightly build logic
<!-- Describe the changes in this PR -->

Disable the scheduled run for `helm-docker` nightly and make `pypi-nightly-build` trigger `helm-docker` instead.  

This ensures `helm-docker` runs only after `pypi` updates (event-based instead of time-based).  

Since we now control everything in `pypi-nightly-build`, rename it to `nightly-build` for better clarity.  

Resolve #5712

<!-- Describe the tests ran -->
<!-- Unit tests (tests/test_*.py) are part of GitHub CI; below are tests that launch on the cloud. -->

Tested (run the relevant ones):

- [x] Code formatting: install pre-commit (auto-check on commit) or `bash format.sh`
- [ ] Any manual or new tests for this PR (please specify below)
- [ ] All smoke tests: `/smoke-test` (CI) or `pytest tests/test_smoke.py` (local)
- [ ] Relevant individual tests: `/smoke-test -k test_name` (CI) or `pytest tests/test_smoke.py::test_name` (local)
- [ ] Backward compatibility: `/quicktest-core` (CI) or `pytest tests/smoke_tests/test_backward_compat.py` (local)

<!-- CI commands (/-prefixed) can only be triggered by repo members -->


Patch:
diff --git a/.github/workflows/helm-docker-release.yaml b/.github/workflows/helm-docker-release.yaml
index fe93f6497..1a4c16adc 100644
--- a/.github/workflows/helm-docker-release.yaml
+++ b/.github/workflows/helm-docker-release.yaml
@@ -2,9 +2,19 @@ name: Build and Publish Helm Charts
 description: Build and publish docker image and Helm charts for SkyPilot.
 
 on:
-  schedule:
-    # Set the time to be 1 hour after the pypi nightly build
-    - cron: '35 11 * * *' # 11:35am UTC, 3:35am PST, 6:35am EST
+  workflow_call:
+    inputs:
+      package_name:
+        description: 'SkyPilot PyPI package name'
+        required: true
+        type: string
+    secrets:
+      DOCKER_USERNAME:
+        required: true
+      DOCKER_PASSWORD:
+        required: true
+      HELM_DEPLOY_KEY:
+        required: true
   workflow_dispatch:
     inputs:
       package_name:
@@ -20,9 +30,16 @@ jobs:
   set-package:
     runs-on: ubuntu-latest
     outputs:
-      package_name: ${{ github.event_name == 'workflow_dispatch' && inputs.package_name || 'skypilot-nightly' }}
+      package_name: ${{ inputs.package_name }}
     steps:
-      - run: echo "Using package ${{ github.event_name == 'workflow_dispatch' && inputs.package_name || 'skypilot-nightly' }}"
+      - name: Validate package name
+        run: |
+          if [[ "${{ inputs.package_name }}" != "skypilot-nightly" && "${{ inputs.package_name }}" != "skypilot" ]]; then
+            echo "Error: package_name must be either 'skypilot-nightly' or 'skypilot'"
+            exit 1
+          fi
+      - name: Echo package name
+        run: echo "Using package ${{ inputs.package_name }}"
 
   docker-build:
     needs: set-package
diff --git a/.github/workflows/pypi-nightly-build.yml b/.github/workflows/nightly-build.yml
similarity index 96%
rename from .github/workflows/pypi-nightly-build.yml
rename to .github/workflows/nightly-build.yml
index a6424f6b4..fac932794 100644
--- a/.github/workflows/pypi-nightly-build.yml
+++ b/.github/workflows/nightly-build.yml
@@ -127,9 +127,16 @@ jobs:
       repository_type: 'pypi'
     secrets: inherit
 
+  trigger-helm-release:
+    needs: [publish-and-validate-pypi]
+    uses: ./.github/workflows/helm-docker-release.yaml
+    with:
+      package_name: skypilot-nightly
+    secrets: inherit
+
   notify-slack-failure:
     runs-on: ubuntu-latest
-    needs: [check-date, nightly-build-pypi, extract-build-number, wait-for-buildkite, publish-and-validate-test-pypi, publish-and-validate-pypi]
+    needs: [check-date, nightly-build-pypi, extract-build-number, wait-for-buildkite, publish-and-validate-test-pypi, publish-and-validate-pypi, trigger-helm-release]
     if: failure() # Only run this job if any of the previous jobs failed
     steps:
       - name: Prepare failure message
diff --git a/Dockerfile b/Dockerfile
index 558b64057..39a1df265 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -23,6 +23,7 @@ RUN conda install -c conda-forge google-cloud-sdk && \
     curl -LO "https://dl.k8s.io/release/v1.31.6/bin/linux/$ARCH/kubectl" && \
     install -o root -g root -m 0755 kubectl /usr/local/bin/kubectl && \
     rm kubectl && \
+    curl -sSL https://storage.eu-north1.nebius.cloud/cli/install.sh | NEBIUS_INSTALL_FOLDER=/usr/local/bin bash && \
     # Install uv and skypilot
     curl -LsSf https://astral.sh/uv/install.sh | sh && \
     ~/.local/bin/uv pip install --prerelease allow azure-cli --system && \
diff --git a/charts/skypilot/templates/api-deployment.yaml b/charts/skypilot/templates/api-deployment.yaml
index aa77e375f..64c55cb6a 100644
--- a/charts/skypilot/templates/api-deployment.yaml
+++ b/charts/skypilot/templates/api-deployment.yaml
@@ -87,6 +87,17 @@ spec:
           # The configmap serves as the ground truth for the ssh_node_pools.yaml file, read-only
           ln -sf /var/skypilot/ssh_node_pool/ssh_node_pools.yaml /root/.sky/ssh_node_pools.yaml
           {{- end }}
+          # Nebius credentials mounting
+          # Since the ~/.nebius directory is also used by the Nebius CLI, we mount the credentials to /root/.nebius_credentials
+          # and create a symlink to /root/.nebius. This cannot be done in the init container because the Nebius CLI needs read-write access to ~/.nebius.
+          {{- if .Values.nebiusCredentials.enabled }}
+          echo "Setting up Nebius credentials..."
+          mkdir -p /root/.nebius
+          ln -sf /root/.nebius_credentials/credentials.json /root/.nebius/credentials.json
+          echo "{{ .Values.nebiusCredentials.tenantId }}" >> /root/.nebius/NEBIUS_TENANT_ID.txt
+          # Create a Nebius profile for the nebius CLI to use as default
+          nebius profile create --profile sky --endpoint api.nebius.cloud --service-account-file /root/.nebius/credentials.json || echo "Unable to create Nebius profile."
+          {{- end }}
           {{- if .Values.apiService.sshKeySecret }}
           mkdir -p /root/.ssh
           echo "Linking ssh keys to /root/.ssh"
@@ -167,9 +178,9 @@ spec:
           readOnly: true
         {{- end }}
         {{- if .Values.nebiusCredentials.enabled }}
-        - name: nebius-config
-          mountPath: /root/.nebius
-          readOnly: true
+        - name: nebius-credentials
+          mountPath: /root/.nebius_credentials/credentials.json
+          subPath: credentials.json
         {{- end }}
       initContainers:
       {{- if .Values.awsCredentials.enabled }}
@@ -288,27 +299,6 @@ spec:
         - name: lambda-config
           mountPath: /root/.lambda_cloud
       {{- end }}
-      {{- if .Values.nebiusCredentials.enabled }}
-      - name: create-nebius-credentials
-        {{- with .Values.securityContext }}
-        securityContext:
-          {{- toYaml . | nindent 10 }}
-        {{- end }}
-        image: {{ .Values.apiService.image }}
-        command: ["/bin/sh", "-c"]
-        args:
-        - |
-          echo "Setting up Nebius credentials..."
-          mkdir -p /root/.nebius
-          cp /root/.nebius_cloud/credentials.json /root/.nebius/credentials.json
-          echo "{{ .Values.nebiusCredentials.tenantId }}" >> /root/.nebius/NEBIUS_TENANT_ID.txt
-        volumeMounts:
-        - name: nebius-config
-          mountPath: /root/.nebius
-        - name: nebius-credentials
-          mountPath: /root/.nebius_cloud/credentials.json
-          subPath: credentials.json
-      {{- end }}
       volumes:
       {{- if .Values.storage.enabled }}
       - name: state-volume
@@ -338,8 +328,6 @@ spec:
         emptyDir: {}
       {{- end }}
       {{- if .Values.nebiusCredentials.enabled }}
-      - name: nebius-config
-        emptyDir: {}
       - name: nebius-credentials
         secret:
           secretName: {{ .Values.nebiusCredentials.nebiusSecretName }}
diff --git a/docs/source/reference/training-guide.rst b/docs/source/reference/training-guide.rst
index fd4136a00..568ebecc2 100644
--- a/docs/source/reference/training-guide.rst
+++ b/docs/source/reference/training-guide.rst
@@ -79,7 +79,7 @@ Use high-performance networking
 
         See `GPUDirect-TCPX example <https://docs.skypilot.co/en/latest/examples/performance/gcp_gpu_direct_tcpx.html>`_ for more details.
 
-    .. tab-item:: Nebius Using InfiniBand
+    .. tab-item:: Nebius with InfiniBand
         :sync: nebius-infiniband-tab
 
         InfiniBand is a high-throughput, low-latency networking standard. To accelerate ML, AI and high-performance computing (HPC) workloads that you run in your Managed Service for Kubernetes clusters with GPUs in Nebius, you can interconnect the GPUs using InfiniBand.
diff --git a/examples/nebius_infiniband/README.md b/examples/nebius_infiniband/README.md
index 0f0f792e9..e09ec57fa 100644
--- a/examples/nebius_infiniband/README.md
+++ b/examples/nebius_infiniband/README.md
@@ -1,8 +1,8 @@
-# Using Infiniband in Nebius Managed Kubernetes clusters with SkyPilot
+# Using InfiniBand in Nebius with SkyPilot
 
 To accelerate ML, AI and high-performance computing (HPC) workloads that you run in your Managed Service for Kubernetes clusters with GPUs in Nebius, you can interconnect the GPUs using InfiniBand, a high-throughput, low-latency networking standard.
 
-## TL;DR: Use inifiband on managed Nebius Kubernetes cluster with SkyPilot
+## InfiniBand on Managed Nebius Kubernetes clusters with SkyPilot
 
 With Nebius Kubernetes cluster, you can use SkyPilot to run your jobs with InfiniBand enabled:
 
@@ -86,7 +86,7 @@ export NB_GPU_CLUSTER_ID=$(nebius compute gpu-cluster create \
 
 nebius mk8s node-group create \
   --parent-id $NB_K8S_CLUSTER_ID \
-  --name high1-ib-group \
+  --name infini-ib-group \
   --fixed-node-count 2 \
   --template-resources-platform gpu-h100-sxm \
   --template-resources-preset 8gpu-128vcpu-1600gb \
@@ -94,6 +94,8 @@ nebius mk8s node-group create \
   --template-gpu-settings-drivers-preset cuda12
 ```
 
+Refer to the [Nebius documentation](https://docs.nebius.com/compute/clusters/gpu#fabrics) for how to select the fabric according to the type of GPUs you are going to use.
+
 3. Setup Kubeconfig and setup Nvidia GPUs 
 
 ```bash
@@ -147,3 +149,89 @@ The example result is as below:
 ```bash
 sky launch -c no_infiniband nccl_no_ib.yaml
 ```
+
+## InfiniBand on Nebius VMs with SkyPilot
+
+While the previous section covered InfiniBand setup for managed Kubernetes service, you can also enable InfiniBand directly on Nebius VMs. This approach gives you more flexibility and control over your infrastructure. For detailed instructions, refer to the [Nebius documentation](https://docs.nebius.com/compute/clusters/gpu).
+
+### Automatic InfiniBand Setup with SkyPilot
+
+SkyPilot simplifies the process of setting up InfiniBand-enabled GPU clusters on Nebius VMs. When you launch a cluster with the appropriate configurations, SkyPilot will automatically create a GPU cluster with InfiniBand support and add VMs to the GPU cluster.
+
+To enable automatic InfiniBand setup, you need to configure your `~/.sky/config.yaml` file with the following settings:
+
+```yaml
+nebius:
+  eu-north1:
+    project_id: <project_id>
+    fabric: <fabric>
+```
+
+Where:
+- `<project_id>`: Your Nebius project identifier
+- `<fabric>`: The GPU cluster configuration identifier that determines the InfiniBand fabric type
+
+For detailed information about fabric selection based on your GPU requirements, consult the [Nebius documentation](https://docs.nebius.com/compute/clusters/gpu#fabrics).
+
+Additional configuration options are available in the [SkyPilot Nebius configuration reference](https://docs.skypilot.co/en/latest/reference/config.html#nebius).
+
+### Running Performance Tests
+
+You can verify your InfiniBand setup by running either of these tests:
+
+1. NCCL Performance Test:
+
+```bash
+sky launch -c infiniband nccl_vm_ib.yaml
+```
+
+Result example:
+
+```
+#                                                              out-of-place                       in-place
+#       size         count      type   redop    root     time   algbw   busbw #wrong     time   algbw   busbw 
+#        (B)    (elements)                               (us)  (GB/s)  (GB/s)            (us)  (GB/s)  (GB/s)
+   536870912     134217728     float     sum      -1   2424.4  221.45  415.21      0   2391.0  224.53  421.00      
+  1073741824     268435456     float     sum      -1   4528.1  237.13  444.62      0   4533.5  236.85  444.09      
+  2147483648     536870912     float     sum      -1   8795.2  244.17  457.81      0   8783.6  244.49  458.42      
+  4294967296    1073741824     float     sum      -1    17442  246.25  461.71      0    17386  247.03  463.19      
+  8589934592    2147483648     float     sum      -1    34430  249.49  467.79      0    34443  249.39  467.61      
+# Out of bounds values : 0 OK
+# Avg bus bandwidth    : 450.146
+```
+
+2. InfiniBand Direct Test:
+
+```bash
+sky launch -c infiniband infiniband.yaml
+```
+
+Result example:
+
+```
+---------------------------------------------------------------------------------------
+                    Send BW Test
+ Dual-port       : OFF          Device         : mlx5_0
+ Number of qps   : 1            Transport type : IB
+ Connection type : RC           Using SRQ      : OFF
+ PCIe relax order: ON
+ ibv_wr* API     : ON
+ TX depth        : 128
+ CQ Moderation   : 1
+ Mtu             : 4096[B]
+ Link type       : IB
+ Max inline data : 0[B]
+ rdma_cm QPs     : OFF
+ Data ex. method : Ethernet
+---------------------------------------------------------------------------------------
+ local address: LID 0x4624 QPN 0x0127 PSN 0x45bd8e
+ remote address: LID 0x461b QPN 0x0127 PSN 0x1d3746
+---------------------------------------------------------------------------------------
+ #bytes     #iterations    BW peak[Gb/sec]    BW average[Gb/sec]   MsgRate[Mpps]
+ 65536      1000             357.12             353.53             0.674308
+---------------------------------------------------------------------------------------
+```
+
+## Additional Resources
+
+The Nebius team maintains a comprehensive collection of example configurations in their [ml-cookbook repository](https://github.com/nebius/ml-cookbook/tree/main/skypilot). These examples cover various use cases and can help you get started with different ML workloads on Nebius using SkyPilot.
\ No newline at end of file
diff --git a/examples/nebius_infiniband/infiniband.yaml b/examples/nebius_infiniband/infiniband.yaml
new file mode 100644
index 000000000..c195a56dd
--- /dev/null
+++ b/examples/nebius_infiniband/infiniband.yaml
@@ -0,0 +1,21 @@
+# This example is used to test the InfiniBand
+# connection between two VMs.
+resources:
+  cloud: nebius
+  region: eu-north1
+  accelerators: H100:8
+  
+num_nodes: 2
+
+setup: |
+  sudo apt install perftest -y
+
+run: |
+  MASTER_ADDR=$(echo "$SKYPILOT_NODE_IPS" | head -n1)
+  if [ "${SKYPILOT_NODE_RANK}" == "0" ]; then
+      ib_send_bw --report_gbits -n 1000 -F > /dev/null
+  elif [ "${SKYPILOT_NODE_RANK}" == "1" ]; then
+      echo "MASTER_ADDR: $MASTER_ADDR"
+      sleep 2 # wait for the master to start
+      ib_send_bw $MASTER_ADDR --report_gbits -n 1000 -F
+  fi
diff --git a/examples/nebius_infiniband/nccl.yaml b/examples/nebius_infiniband/nccl.yaml
index b80f14209..b0d8a1a56 100644
--- a/examples/nebius_infiniband/nccl.yaml
+++ b/examples/nebius_infiniband/nccl.yaml
@@ -1,3 +1,5 @@
+# This example is used to test the NCCL performance with
+# InfiniBand on managed Nebius Kubernetes cluster.
 name: nccl-test
 
 resources:
diff --git a/examples/nebius_infiniband/nccl_no_ib.yaml b/examples/nebius_infiniband/nccl_no_ib.yaml
index dd4d71d69..2879eda5f 100644
--- a/examples/nebius_infiniband/nccl_no_ib.yaml
+++ b/examples/nebius_infiniband/nccl_no_ib.yaml
@@ -1,3 +1,5 @@
+# This example is used to test the NCCL performance without
+# InfiniBand on managed Nebius Kubernetes cluster.
 name: nccl-test
 
 resources:
diff --git a/examples/nebius_infiniband/nccl_vm_ib.yaml b/examples/nebius_infiniband/nccl_vm_ib.yaml
new file mode 100644
index 000000000..eff536c69
--- /dev/null
+++ b/examples/nebius_infiniband/nccl_vm_ib.yaml
@@ -0,0 +1,57 @@
+# This example is used to test the NCCL performance
+# with InfiniBand on Nebius VMs.
+resources:
+  cloud: nebius
+  region: eu-north1
+  accelerators: H100:8
+  image_id: docker:cr.eu-north1.nebius.cloud/nebius-benchmarks/nccl-tests:2.23.4-ubu22.04-cu12.4
+
+num_nodes: 2
+
+setup: |
+  sudo apt-get install -y iproute2
+
+run: | 
+  # port 10022 in containers on Nebius VMs
+  export SSH_PORT=$(ss -tlnp | grep sshd | awk '{print $4}' | cut -d':' -f2)
+  export NCCL_SOCKET_IFNAME=$(ip -o -4 route show to default | awk '{print $5}')
+
+  # Total number of processes, NP should be the total number of GPUs in the cluster
+  NP=$(($SKYPILOT_NUM_GPUS_PER_NODE * $SKYPILOT_NUM_NODES))
+
+  # Append :${SKYPILOT_NUM_GPUS_PER_NODE} to each IP as slots
+  nodes=""
+  for ip in $SKYPILOT_NODE_IPS; do
+    nodes="${nodes}${ip}:${SKYPILOT_NUM_GPUS_PER_NODE},"
+  done
+  nodes=${nodes::-1}
+
+  if [ "${SKYPILOT_NODE_RANK}" == "0" ]; then
+    mpirun \
+      --allow-run-as-root \
+      -H $nodes \
+      -np $NP \
+      -N $SKYPILOT_NUM_GPUS_PER_NODE \
+      -bind-to none \
+      -x LD_LIBRARY_PATH \
+      -x NCCL_SOCKET_IFNAME \
+      -x NCCL_IB_HCA=mlx5 \
+      -x NCCL_ALGO=NVLSTree \
+      -x UCX_NET_DEVICES=mlx5_0:1,mlx5_1:1,mlx5_2:1,mlx5_3:1,mlx5_4:1,mlx5_5:1,mlx5_6:1,mlx5_7:1 \
+      -x SHARP_COLL_ENABLE_PCI_RELAXED_ORDERING=1 \
+      -x NCCL_COLLNET_ENABLE=0 \
+      --mca plm_rsh_args "-p $SSH_PORT" \
+      /opt/nccl_tests/build/all_reduce_perf \
+      -b 512M \
+      -e 8G \
+      -f 2 \
+      -g 1
+  else
+    echo "worker node"
+  fi
+
+config:
+  docker:
+    run_options:
+      - --device=/dev/infiniband
+      - --cap-add=IPC_LOCK
diff --git a/sky/client/sdk.py b/sky/client/sdk.py
index 0e6035818..879c858f8 100644
--- a/sky/client/sdk.py
+++ b/sky/client/sdk.py
@@ -1921,6 +1921,7 @@ def api_login(endpoint: Optional[str] = None, get_token: bool = False) -> None:
     if (endpoint is not None and not endpoint.startswith('http://') and
             not endpoint.startswith('https://')):
         raise click.BadParameter('Endpoint must be a valid URL.')
+    endpoint = endpoint.rstrip('/')
 
     server_status = server_common.check_server_healthy(endpoint)
     if server_status == server_common.ApiServerStatus.NEEDS_AUTH or get_token:
diff --git a/sky/server/constants.py b/sky/server/constants.py
index bdeff981a..7bb97dee6 100644
--- a/sky/server/constants.py
+++ b/sky/server/constants.py
@@ -7,7 +7,7 @@ from sky.skylet import constants
 # API server version, whenever there is a change in API server that requires a
 # restart of the local API server or error out when the client does not match
 # the server version.
-API_VERSION = '5'
+API_VERSION = '6'
 
 # Prefix for API request names.
 REQUEST_NAME_PREFIX = 'sky.'
diff --git a/sky/server/server.py b/sky/server/server.py
index a7bf3050f..1f218fe97 100644
--- a/sky/server/server.py
+++ b/sky/server/server.py
@@ -127,6 +127,11 @@ class AuthProxyMiddleware(starlette.middleware.base.BaseHTTPMiddleware):
 
     async def dispatch(self, request: fastapi.Request, call_next):
         auth_user = _get_auth_user_header(request)
+
+        # Add user to database if auth_user is present
+        if auth_user is not None:
+            global_user_state.add_or_update_user(auth_user)
+
         body = await request.body()
         if auth_user and body:
             try:
@@ -137,10 +142,16 @@ class AuthProxyMiddleware(starlette.middleware.base.BaseHTTPMiddleware):
                 logger.debug(f'Overriding user for {request.state.request_id}: '
                              f'{auth_user.name}, {auth_user.id}')
                 if 'env_vars' in original_json:
-                    original_json['env_vars'][
-                        constants.USER_ID_ENV_VAR] = auth_user.id
-                    original_json['env_vars'][
-                        constants.USER_ENV_VAR] = auth_user.name
+                    if isinstance(original_json.get('env_vars'), dict):
+                        original_json['env_vars'][
+                            constants.USER_ID_ENV_VAR] = auth_user.id
+                        original_json['env_vars'][
+                            constants.USER_ENV_VAR] = auth_user.name
+                    else:
+                        logger.warning(
+                            f'"env_vars" in request body is not a dictionary '
+                            f'for request {request.state.request_id}. '
+                            'Skipping user info injection into body.')
                 request._body = json.dumps(original_json).encode('utf-8')  # pylint: disable=protected-access
         return await call_next(request)
 
@@ -262,10 +273,7 @@ app.include_router(workspaces_rest.router,
 
 @app.get('/token')
 async def token(request: fastapi.Request) -> fastapi.responses.HTMLResponse:
-    # If we have auth info, save this user to the database.
     user = _get_auth_user_header(request)
-    if user is not None:
-        global_user_state.add_or_update_user(user)
 
     token_data = {
         'v': 1,  # Token version number, bump for backwards incompatible.
diff --git a/sky/utils/kubernetes/exec_kubeconfig_converter.py b/sky/utils/kubernetes/exec_kubeconfig_converter.py
index 08c629dbc..c24cda374 100644
--- a/sky/utils/kubernetes/exec_kubeconfig_converter.py
+++ b/sky/utils/kubernetes/exec_kubeconfig_converter.py
@@ -5,6 +5,9 @@ the 'command' field in the exec configuration, leaving only the executable name.
 This is useful when moving between different environments where auth plugin
 executables might be installed in different locations.
 
+For Nebius kubeconfigs, it also changes the --profile argument to 'sky' to
+ensure compatibility with SkyPilot's expected profile configuration.
+
 It assumes the target environment has the auth executable available in PATH.
 If not, you'll need to update your environment container to include the auth
 executable in PATH.
@@ -21,6 +24,8 @@ import yaml
 def strip_auth_plugin_paths(kubeconfig_path: str, output_path: str):
     """Strip path information from exec plugin commands in a kubeconfig file.
 
+    For Nebius kubeconfigs, also changes the --profile argument to 'sky'.
+
     Args:
         kubeconfig_path (str): Path to the input kubeconfig file
         output_path (str): Path where the modified kubeconfig will be saved
@@ -40,6 +45,20 @@ def strip_auth_plugin_paths(kubeconfig_path: str, output_path: str):
                 exec_info['command'] = executable
                 updated = True
 
+            # Handle Nebius kubeconfigs: change --profile to 'sky'
+            if executable == 'nebius' or current_command == 'nebius':
+                args = exec_info.get('args', [])
+                if args and '--profile' in args:
+                    try:
+                        profile_index = args.index('--profile')
+                        if profile_index + 1 < len(args):
+                            old_profile = args[profile_index + 1]
+                            if old_profile != 'sky':
+                                args[profile_index + 1] = 'sky'
+                                updated = True
+                    except ValueError:
+                        pass  # --profile not found in args
+
     if updated:
         with open(output_path, 'w', encoding='utf-8') as file:
             yaml.safe_dump(config, file)


Test patch:
diff --git a/tests/unit_tests/test_sky/server/test_sdk.py b/tests/unit_tests/test_sky/server/test_sdk.py
index 3aef432ba..ca45c8a73 100644
--- a/tests/unit_tests/test_sky/server/test_sdk.py
+++ b/tests/unit_tests/test_sky/server/test_sdk.py
@@ -158,3 +158,51 @@ def test_api_login(monkeypatch: pytest.MonkeyPatch, tmp_path: Path):
     # Test with invalid endpoint
     with pytest.raises(click.BadParameter):
         client_sdk.api_login("invalid_endpoint")
+
+    # Test with endpoint ending with a slash
+    test_endpoint_with_slash = "http://test3.skypilot.co/"
+    with mock.patch('sky.server.common.check_server_healthy') as mock_check:
+        mock_check.return_value = None
+        client_sdk.api_login(test_endpoint_with_slash)
+        config = skypilot_config.get_user_config()
+        # Endpoint should be stored without the trailing slash
+        assert config["api_server"]["endpoint"] == "http://test3.skypilot.co"
+        mock_check.assert_called_once_with("http://test3.skypilot.co")
+
+    # Test with https endpoint
+    test_https_endpoint = "https://secure.skypilot.co"
+    with mock.patch('sky.server.common.check_server_healthy') as mock_check:
+        mock_check.return_value = None
+        client_sdk.api_login(test_https_endpoint)
+        config = skypilot_config.get_user_config()
+        assert config["api_server"]["endpoint"] == test_https_endpoint
+        mock_check.assert_called_once_with(test_https_endpoint)
+
+    # Test with https endpoint ending with a slash
+    test_https_endpoint_with_slash = "https://secure.skypilot.co/"
+    with mock.patch('sky.server.common.check_server_healthy') as mock_check:
+        mock_check.return_value = None
+        client_sdk.api_login(test_https_endpoint_with_slash)
+        config = skypilot_config.get_user_config()
+        # Endpoint should be stored without the trailing slash
+        assert config["api_server"]["endpoint"] == "https://secure.skypilot.co"
+        mock_check.assert_called_once_with("https://secure.skypilot.co")
+
+    # Test with endpoint containing port number
+    test_endpoint_with_port = "http://localhost:8080"
+    with mock.patch('sky.server.common.check_server_healthy') as mock_check:
+        mock_check.return_value = None
+        client_sdk.api_login(test_endpoint_with_port)
+        config = skypilot_config.get_user_config()
+        assert config["api_server"]["endpoint"] == test_endpoint_with_port
+        mock_check.assert_called_once_with(test_endpoint_with_port)
+
+    # Test with endpoint containing port number and trailing slash
+    test_endpoint_with_port_slash = "http://localhost:8080/"
+    with mock.patch('sky.server.common.check_server_healthy') as mock_check:
+        mock_check.return_value = None
+        client_sdk.api_login(test_endpoint_with_port_slash)
+        config = skypilot_config.get_user_config()
+        # Endpoint should be stored without the trailing slash
+        assert config["api_server"]["endpoint"] == "http://localhost:8080"
+        mock_check.assert_called_once_with("http://localhost:8080")