Repository: friendliai/periflow-cli (language: python)

Issue:
Sync to latest PFS server
- Sync to latest PFS server
- Add PFS template cmd
- Add `settings.py` file to separate pft/pfs and server urls


Discussion / hints:

Fix patch:
diff --git a/.gitignore b/.gitignore
index e6497bc..f0e52e9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -157,5 +157,8 @@ cython_debug/
 .idea/
 workspace*
 
+# VSCode
+.vscode
+
 # Example (temp)
 example
diff --git a/README.md b/README.md
index 8becb44..2e99f53 100644
--- a/README.md
+++ b/README.md
@@ -126,9 +126,6 @@ Do you want to open an editor to configure the job YAML file? (default editor: n
 Then, the configuration template file will be saved at `/path/to/config.yaml` with the following content.
 
 ```yaml
-# The name of experiment
-experiment:
-
 # The name of job
 name:
 
diff --git a/VERSION b/VERSION
index c946ee6..1180819 100644
--- a/VERSION
+++ b/VERSION
@@ -1,1 +1,1 @@
-0.1.6
+0.1.7
diff --git a/doc/tutorial/kr/common_step.md b/doc/tutorial/kr/common_step.md
index 78de963..8a2ba5e 100644
--- a/doc/tutorial/kr/common_step.md
+++ b/doc/tutorial/kr/common_step.md
@@ -13,7 +13,6 @@ PeriFlow를 사용하시는 여러분들을 환영합니다. 본 튜토리얼에
 - **Organization**: PeriFlow의 최상위 그룹 단위입니다. 조직 내에 멤버들을 초대하여 개발 및 실험 과정을 협업할 수 있습니다. 사용자는 동시에 여러 개의 Organization에 속할 수 있습니다.
 - **Project**: Organization 내의 서브 그룹의 단위입니다. Organization 내에서 용도에 따라 여러 개의 Project를 생성하고 각 Project 마다 필요한 멤버들을 추가할 수 있습니다. Project 내에서는 Job, Dataset, Checkpoint, Credential 등의 모든 자원이 공유됩니다. 사용자는 동시에 여러 개의 Project에 속할 수 있습니다.
 - **Job**: 학습 작업의 실행/스케줄링 단위 입니다. `python main.py ...`와 같은 명령어를 사용하여 학습 프로세스를 실행하는 것이 PeriFlow에선 Job 하나에 해당 합니다.
-- **Experiment**: 여러 Job들의 묶음으로, Job에 붙이는 태그에 해당합니다. 같은 종류의 Job은 같은 Experiment에 묶어서 보다 편하게 Job을 관리할 수 있습니다.
 - **Dataset**: Job에 사용될 학습 데이터입니다.
 - **Checkpoint**: 모델 학습의 결과물로 생긴 모델 가중치 체크포인트입니다. 학습 과정의 특정 스텝에서 Checkpoint 하나가 생성될 수 있습니다.
 - **Credential**: 유저의 개인 클라우드 저장소, Slack 등에 접근하기 위해 필요한 secret 입니다.
diff --git a/doc/tutorial/kr/huggingface_training.md b/doc/tutorial/kr/huggingface_training.md
index 19e7d6a..87d93a9 100644
--- a/doc/tutorial/kr/huggingface_training.md
+++ b/doc/tutorial/kr/huggingface_training.md
@@ -54,9 +54,6 @@ class PeriFlowCallback(TrainerCallback):
 MNLI dataset을 업로드 했고, 로컬에 SDK가 적용된 코드가 준비되었다면, 마지막으로 YAML 파일로 Job의 세부사항을 명시하는 과정만이 남았습니다. [`pf-template.yml`](https://github.com/friendliai/periflow-python-sdk/blob/main/examples/huggingface/pf-template.yml)에는 `run_glue.py`를 수행하기 위한 configuration 예시가 나와 있습니다.
 
 ```yaml
-# The name of experiment
-experiment: huggingface-text-classification
-
 # The name of job
 name: mnli-job
 
diff --git a/doc/tutorial/kr/pth_lightning_training.md b/doc/tutorial/kr/pth_lightning_training.md
index dac441d..c5d742b 100644
--- a/doc/tutorial/kr/pth_lightning_training.md
+++ b/doc/tutorial/kr/pth_lightning_training.md
@@ -75,9 +75,6 @@ class PeriFlowTrainer(Trainer):
 MNIST dataset을 업로드 했고, 로컬에 SDK가 적용된 코드가 준비되었다면, 마지막으로 YAML 파일로 Job의 세부사항을 명시하는 과정만이 남았습니다. [`pf-template.yml`](https://github.com/friendliai/periflow-python-sdk/blob/main/examples/pth-lightning/pf-template.yml)에는 `main.py`를 수행하기 위한 configuration 예시가 나와 있습니다.
 
 ```yaml
-# The name of experiment
-experiment: mnist-autoencoder
-
 # The name of job
 name: autoencoder-job
 
diff --git a/doc/tutorial/kr/pytorch_training.md b/doc/tutorial/kr/pytorch_training.md
index 7d6b40e..ebfdfdb 100644
--- a/doc/tutorial/kr/pytorch_training.md
+++ b/doc/tutorial/kr/pytorch_training.md
@@ -104,9 +104,6 @@ CIFAR-100 데이터셋(`my-cifar-100`)을 업로드 했고, 로컬에 SDK가 적
 ```yaml
 # pf-template.yml
 
-# The name of experiment
-experiment: computer-vision
-
 # The name of job
 name: cifar-job
 
@@ -160,7 +157,6 @@ data:
 
 각 필드가 무엇을 의미 하는지 살펴보도록 하겠습니다.
 
-- `experiment`: 내가 돌릴 Job의 Experiment(i.e., 여러 Job의 묶음, Job의 tag)를 설정합니다. Experiment는 `pf experiment create` 명령어를 통해 생성이 가능하며, 만약 존재하지 않는 Experiment를 사용하려고 하면 `pf job run` 명령어 실행시에 새로운 Experiment를 생성할 수도 있습니다.
 - `name`: **[Optional]** Job의 이름입니다. Job의 이름은 중복이 가능합니다.
 - `vm`: Job에서 사용할 클라우드 머신의 유형을 입력합니다. 사용 가능한 머신의 유형은 `pf vm list` 명령어를 통해 확인이 가능합니다.
 
diff --git a/pfcli/artifact.py b/pfcli/artifact.py
index ac3875f..f570854 100644
--- a/pfcli/artifact.py
+++ b/pfcli/artifact.py
@@ -10,8 +10,9 @@
 
 from pfcli.service import ServiceType
 from pfcli.service.client import build_client
-from pfcli.service.client.job import JobArtifactClientService
-from pfcli.utils import download_file, secho_error_and_exit
+from pfcli.service.client.job import ProjectJobArtifactClientService
+from pfcli.utils.fs import download_file
+from pfcli.utils.format import secho_error_and_exit
 
 
 app = typer.Typer(
@@ -36,8 +37,8 @@ def download(
 
     save_directory = save_directory or Path(os.getcwd())
 
-    client: JobArtifactClientService = build_client(
-        ServiceType.JOB_ARTIFACT, job_id=job_id
+    client: ProjectJobArtifactClientService = build_client(
+        ServiceType.PROJECT_JOB_ARTIFACT, job_id=job_id
     )
     all_artifacts = client.list_artifacts()
     for i, artifact in enumerate(all_artifacts):
diff --git a/pfcli/billing.py b/pfcli/billing.py
index 26d9037..af9131d 100644
--- a/pfcli/billing.py
+++ b/pfcli/billing.py
@@ -1,18 +1,21 @@
 # Copyright (C) 2021 FriendliAI
 
-from collections import defaultdict
+from datetime import datetime, timedelta
 from functools import reduce
 from typing import Optional
 
 import tabulate
 import typer
 
-from pfcli.service import ServiceType
+from pfcli.service import PeriFlowService, ServiceType
 from pfcli.service.client import build_client
-from pfcli.service.client.billing import BillingClientService
-from pfcli.context import get_current_group_id, get_current_project_id
+from pfcli.service.client.billing import PFTBillingClientService, TimeGranularity
 from pfcli.service.formatter import PanelFormatter, TableFormatter
-from pfcli.utils import secho_error_and_exit
+from pfcli.utils.format import (
+    datetime_to_simple_string,
+    secho_error_and_exit,
+    utc_to_local,
+)
 
 
 tabulate.PRESERVE_WHITESPACE = True
@@ -25,8 +28,8 @@
 
 table_formatter = TableFormatter(
     name="Billing",
-    fields=["vm_name", "price"],
-    headers=["VM Name", "Amount used in USD"],
+    fields=["start_time", "end_time", "price"],
+    headers=["Start Time", "End Time", "Amount used in USD"],
 )
 
 panel_formatter = PanelFormatter(
@@ -34,46 +37,111 @@
 )
 
 
-@app.command(help="summarize billing information")
-def summary(
-    year: int = typer.Argument(...),
-    month: int = typer.Argument(...),
-    day: Optional[int] = typer.Argument(None),
-    view_organization: bool = typer.Option(
-        False, "--organization", "-o", help="View organization-level cost summary"
-    ),
+def billing_summary_for_train(
+    year: int,
+    month: int,
+    day: Optional[int],
+    view_project: bool,
+    view_organization: bool,
+    time_granularity: Optional[TimeGranularity],
 ):
-    "Summarize the billing information for the given time range"
-    client: BillingClientService = build_client(ServiceType.BILLING_SUMMARY)
-    group_id = None
-    project_id = None
-    if not view_organization:
-        project_id = get_current_project_id()
-        if project_id is None:
-            secho_error_and_exit("Project is not set!")
-    else:
-        group_id = get_current_group_id()
-        if group_id is None:
-            secho_error_and_exit("Organization is not set!")
+    client: PFTBillingClientService = build_client(ServiceType.PFT_BILLING_SUMMARY)
+
+    agg_by = "user_id"
+
+    if view_project:
+        agg_by = "project_id"
+
+    if view_organization:
+        agg_by = "organization_id"
+
+    try:
+        if day is None:
+            start_date = datetime(year, month, 1).astimezone()
+            end_date = (
+                datetime(year + int(month == 12), (month + 1) if month < 12 else 1, 1)
+                - timedelta(days=1)
+            ).astimezone()
+        else:
+            start_date = datetime(year, month, day).astimezone()
+            end_date = (datetime(year, month, day) + timedelta(days=1)).astimezone()
+    except ValueError as exc:
+        secho_error_and_exit(f"Failed to parse datetime: {exc}")
 
     prices = client.list_prices(
-        year=year, month=month, day=day, group_id=group_id, project_id=project_id
+        start_date=start_date.isoformat(),
+        end_date=end_date.isoformat(),
+        agg_by=agg_by,
+        time_granularity=time_granularity,
     )
 
-    price_by_vmname = defaultdict(float)
-
+    total_price = 0
     for price_info in prices:
-        attributes = price_info["instance"]["attributes"]
-        if "vm_name" in attributes:
-            price_by_vmname[attributes["vm_name"]] += price_info["price"]
-    table_formatter.render(
-        [
-            {"vm_name": vm_name, "price": round(price, 2)}
-            for vm_name, price in price_by_vmname.items()
-        ]
+        price_info["start_time"] = datetime_to_simple_string(
+            utc_to_local(
+                datetime.strptime(price_info["start_time"], "%Y-%m-%dT%H:%M:%SZ")
+            )
+        )
+        price_info["end_time"] = datetime_to_simple_string(
+            utc_to_local(
+                datetime.strptime(price_info["end_time"], "%Y-%m-%dT%H:%M:%SZ")
+            )
+        )
+        aggregated_price = reduce(
+            lambda acc, x: acc + x["price"], price_info["price_list"], 0.0
+        )
+        price_info["price"] = round(aggregated_price, 2)
+        total_price += aggregated_price
+
+    table_formatter.render(prices)
+    panel_formatter.render([{"price": round(total_price, 2)}])
+
+
+def billing_summary_for_serve(
+    year: int,
+    month: int,
+    day: Optional[int],
+    view_project: bool,
+    view_organization: bool,
+    time_granularity: Optional[TimeGranularity],
+):
+    # TODO: FILL ME
+    secho_error_and_exit(
+        "VM list for the deployment is not supported yet. Please contact the support team."
     )
 
-    price_sum = reduce((lambda acc, x: acc + x), price_by_vmname.values(), 0.0)
 
-    total_price = [{"price": round(price_sum, 2)}]
-    panel_formatter.render(total_price)
+@app.command(help="summarize billing information")
+def summary(
+    service: PeriFlowService = typer.Option(
+        ...,
+        "--service",
+        "-s",
+        help="PeriFlow service type to see service usage and costs.",
+    ),
+    year: int = typer.Argument(...),
+    month: int = typer.Argument(...),
+    day: Optional[int] = typer.Argument(None),
+    view_project: bool = typer.Option(
+        False, "--project", "-p", help="View project-level cost summary."
+    ),
+    view_organization: bool = typer.Option(
+        False, "--organization", "-o", help="View organization-level cost summary."
+    ),
+    time_granularity: Optional[TimeGranularity] = typer.Option(
+        None, "--time-granularity", "-t", help="View within the given time granularity."
+    ),
+):
+    """Summarize the billing information for the given time range"""
+    handler_map = {
+        PeriFlowService.TRAIN: billing_summary_for_train,
+        PeriFlowService.SERVE: billing_summary_for_serve,
+    }
+    handler_map[service](
+        year=year,
+        month=month,
+        day=day,
+        view_project=view_project,
+        view_organization=view_organization,
+        time_granularity=time_granularity,
+    )
diff --git a/pfcli/checkpoint.py b/pfcli/checkpoint.py
index 42bbd9e..47f5a42 100644
--- a/pfcli/checkpoint.py
+++ b/pfcli/checkpoint.py
@@ -5,11 +5,13 @@
 import os
 from dateutil.parser import parse
 from typing import Optional, List
+from uuid import UUID
 
 import typer
 
 from pfcli.service import (
     CheckpointCategory,
+    CredType,
     ModelFormCategory,
     ServiceType,
     StorageType,
@@ -25,7 +27,9 @@
 )
 from pfcli.service.cloud import build_storage_helper
 from pfcli.service.formatter import PanelFormatter, TableFormatter, TreeFormatter
-from pfcli.utils import datetime_to_pretty_str, download_file, secho_error_and_exit
+from pfcli.utils.format import datetime_to_pretty_str, secho_error_and_exit
+from pfcli.utils.fs import download_file
+from pfcli.utils.validate import validate_cloud_storage_type, validate_parallelism_order
 
 
 app = typer.Typer(
@@ -79,15 +83,6 @@
 tree_formatter = TreeFormatter(name="Files")
 
 
-def _validate_parallelism_order(value: str) -> List[str]:
-    parallelism_order = value.split(",")
-    if {"pp", "dp", "mp"} != set(parallelism_order):
-        secho_error_and_exit(
-            "Invalid Argument: parallelism_order should contain 'pp', 'dp', 'mp'"
-        )
-    return parallelism_order
-
-
 @app.command()
 def list(
     category: Optional[CheckpointCategory] = typer.Option(
@@ -112,7 +107,7 @@ def list(
 
 @app.command()
 def view(
-    checkpoint_id: str = typer.Argument(
+    checkpoint_id: UUID = typer.Argument(
         ..., help="UUID of checkpoint to inspect detail."
     )
 ):
@@ -138,11 +133,12 @@ def create(
         "--format",
         help="The format of your checkpoint",
     ),
-    cloud: StorageType = typer.Option(
+    cloud_storage: StorageType = typer.Option(
         ...,
-        "--cloud",
+        "--cloud-storage",
         "-c",
-        help="The cloud storage vendor type where the checkpoint is uploaded.",
+        help="The cloud storage vendor where the checkpoint is uploaded.",
+        callback=validate_cloud_storage_type,
     ),
     region: str = typer.Option(
         ...,
@@ -162,7 +158,7 @@ def create(
         "-p",
         help="File or direcotry path of cloud storage. The root of the storage will be used by default.",
     ),
-    credential_id: str = typer.Option(
+    credential_id: UUID = typer.Option(
         ...,
         "--credential-id",
         "-i",
@@ -186,7 +182,7 @@ def create(
     parallelism_order: str = typer.Option(
         "pp,dp,mp",
         "--parallelism-order",
-        callback=_validate_parallelism_order,
+        callback=validate_parallelism_order,
         help="Order of device allocation in distributed training.",
     ),
 ):
@@ -201,12 +197,15 @@ def create(
 
     credential_client: CredentialClientService = build_client(ServiceType.CREDENTIAL)
     credential = credential_client.get_credential(credential_id)
-    if credential["type"] != cred_type_map[cloud]:
+    if credential["type"] != cred_type_map[CredType(cloud_storage.value)]:
         secho_error_and_exit(
-            f"Credential type and cloud vendor mismatch: {cred_type_map_inv[credential['type']]} and {cloud}."
+            "Credential type and cloud vendor mismatch: "
+            f"{cred_type_map_inv[credential['type']]} and {cloud_storage.value}."
         )
 
-    storage_helper = build_storage_helper(cloud, credential_json=credential["value"])
+    storage_helper = build_storage_helper(
+        cloud_storage, credential_json=credential["value"]
+    )
     if storage_path is not None:
         storage_path = storage_path.strip("/")
     files = storage_helper.list_storage_files(storage_name, storage_path)
@@ -219,7 +218,7 @@ def create(
     ckpt = checkpoint_client.create_checkpoint(
         name=name,
         model_form_category=format,
-        vendor=cloud,
+        vendor=cloud_storage,
         region=region,
         credential_id=credential_id,
         iteration=iteration,
@@ -239,7 +238,7 @@ def create(
 
 @app.command()
 def delete(
-    checkpoint_id: str = typer.Argument(..., help="UUID of checkpoint to delete."),
+    checkpoint_id: UUID = typer.Argument(..., help="UUID of checkpoint to delete."),
     force: bool = typer.Option(
         False,
         "--force",
@@ -261,7 +260,7 @@ def delete(
 
 @app.command()
 def download(
-    checkpoint_id: str = typer.Argument(..., help="UUID of checkpoint to download."),
+    checkpoint_id: UUID = typer.Argument(..., help="UUID of checkpoint to download."),
     save_directory: Optional[str] = typer.Option(
         None,
         "--destination",
diff --git a/pfcli/context.py b/pfcli/context.py
index b8a3362..9e81093 100644
--- a/pfcli/context.py
+++ b/pfcli/context.py
@@ -5,7 +5,7 @@
 import uuid
 from typing import Optional
 
-from pfcli.utils import get_periflow_directory
+from pfcli.utils.fs import get_periflow_directory
 
 
 org_context_path = get_periflow_directory() / "organization"
diff --git a/pfcli/credential.py b/pfcli/credential.py
index 28816b5..52b475a 100644
--- a/pfcli/credential.py
+++ b/pfcli/credential.py
@@ -3,6 +3,7 @@
 """PeriFlow Credential CLI"""
 
 import json
+from uuid import UUID
 
 import typer
 
@@ -14,7 +15,7 @@
 )
 from pfcli.service.config import CredentialConfigService
 from pfcli.service.formatter import PanelFormatter, TableFormatter
-from pfcli.utils import secho_error_and_exit
+from pfcli.utils.format import secho_error_and_exit
 
 
 app = typer.Typer(
@@ -233,7 +234,7 @@ def list(
 
 @app.command()
 def update(
-    credential_id: str = typer.Argument(..., help="UUID of credential to update.")
+    credential_id: UUID = typer.Argument(..., help="UUID of credential to update.")
 ):
     """Update a credential data."""
     configurator = CredentialConfigService()
@@ -244,7 +245,7 @@ def update(
     client: CredentialClientService = build_client(ServiceType.CREDENTIAL)
 
     cred = client.update_credential(
-        credential_id, name=name, type_version=1, value=value
+        credential_id, name=name, type_version="1", value=value
     )
     cred["type"] = cred_type_map_inv[cred["type"]].value
 
@@ -254,7 +255,7 @@ def update(
 
 @app.command()
 def delete(
-    credential_id: str = typer.Argument(..., help="UUID of credential to delete."),
+    credential_id: UUID = typer.Argument(..., help="UUID of credential to delete."),
     force: bool = typer.Option(
         False,
         "--force",
diff --git a/pfcli/dataset.py b/pfcli/dataset.py
index d9d5fba..d0bf5a5 100644
--- a/pfcli/dataset.py
+++ b/pfcli/dataset.py
@@ -4,11 +4,12 @@
 
 from pathlib import Path
 from typing import Optional
-from click import Choice
+from uuid import UUID
 
+from click import Choice
+from rich.text import Text
 import typer
 import yaml
-from rich.text import Text
 
 from pfcli.service import (
     StorageType,
@@ -33,7 +34,9 @@
     TableFormatter,
     TreeFormatter,
 )
-from pfcli.utils import secho_error_and_exit, get_file_info
+from pfcli.utils.format import secho_error_and_exit
+from pfcli.utils.fs import get_file_info
+from pfcli.utils.validate import validate_cloud_storage_type
 
 app = typer.Typer(
     no_args_is_help=True,
@@ -127,7 +130,9 @@ def view(
     client: DataClientService = build_client(ServiceType.DATA)
 
     dataset_id = project_client.get_id_by_name(name)
-    dataset = client.get_dataset(dataset_id)
+    if dataset_id is None:
+        secho_error_and_exit(f"Dataset with name ({name}) is not found.")
+    dataset = client.get_dataset(dataset_id)  # type: ignore
     dataset["vendor"] = storage_type_map_inv[dataset["vendor"]].value
     panel_formatter.render([dataset], show_detail=True)
     tree_formatter.render(dataset["files"])
@@ -139,19 +144,20 @@ def create(
     name: str = typer.Option(
         ..., "--name", "-n", help="Name of your dataset to create."
     ),
-    cloud: Optional[StorageType] = typer.Option(
+    cloud_storage: StorageType = typer.Option(
         ...,
-        "--cloud",
+        "--cloud-storage",
         "-c",
-        help="Name of cloud storage vendor where your dataset is uploaded.",
+        help="The cloud storage vendor where your dataset is uploaded.",
+        callback=validate_cloud_storage_type,
     ),
-    region: Optional[str] = typer.Option(
+    region: str = typer.Option(
         ...,
         "--region",
         "-r",
         help="Cloud storage region where your dataset is uploaded.",
     ),
-    storage_name: Optional[str] = typer.Option(
+    storage_name: str = typer.Option(
         ...,
         "--storage-name",
         "-s",
@@ -163,7 +169,7 @@ def create(
         "-p",
         help="File or direcotry path of cloud storage. The root of the storage will be used by default.",
     ),
-    credential_id: Optional[str] = typer.Option(
+    credential_id: UUID = typer.Option(
         ...,
         "--credential-id",
         "-i",
@@ -182,12 +188,13 @@ def create(
     """
     credential_client: CredentialClientService = build_client(ServiceType.CREDENTIAL)
     credential = credential_client.get_credential(credential_id)
-    if credential["type"] != cred_type_map[cloud]:
+    if credential["type"] != cred_type_map[cloud_storage.value]:
         secho_error_and_exit(
-            f"Credential type and cloud vendor mismatch: {cred_type_map_inv[credential['type']]} and {cloud}."
+            "Credential type and cloud vendor mismatch: "
+            f"{cred_type_map_inv[credential['type']]} and {cloud_storage.value}."
         )
 
-    storage_helper = build_storage_helper(cloud, credential["value"])
+    storage_helper = build_storage_helper(cloud_storage, credential["value"])
     if storage_path is not None:
         storage_path = storage_path.strip("/")
     files = storage_helper.list_storage_files(storage_name, storage_path)
@@ -203,7 +210,14 @@ def create(
 
     client: ProjectDataClientService = build_client(ServiceType.PROJECT_DATA)
     dataset = client.create_dataset(
-        name, cloud, region, storage_name, credential_id, metadata, files, True
+        name=name,
+        vendor=cloud_storage,
+        region=region,
+        storage_name=storage_name,
+        credential_id=credential_id,
+        metadata=metadata,
+        files=files,
+        active=True,
     )
     dataset["vendor"] = storage_type_map_inv[dataset["vendor"]].value
 
@@ -235,7 +249,7 @@ def upload(
     client: DataClientService = build_client(ServiceType.DATA)
     project_client: ProjectDataClientService = build_client(ServiceType.PROJECT_DATA)
     expand = source_path.endswith("/")
-    source_path: Path = Path(source_path)
+    src_path: Path = Path(source_path)
 
     dataset_id = project_client.get_id_by_name(name)
     if dataset_id is not None:
@@ -256,8 +270,8 @@ def upload(
 
     try:
         typer.echo(f"Start uploading objects to dataset ({name})...")
-        spu_targets = expand_paths(source_path, expand, FileSizeType.SMALL)
-        mpu_targets = expand_paths(source_path, expand, FileSizeType.LARGE)
+        spu_targets = expand_paths(src_path, expand, FileSizeType.SMALL)
+        mpu_targets = expand_paths(src_path, expand, FileSizeType.LARGE)
         spu_url_dicts = (
             client.get_spu_urls(dataset_id=dataset_id, paths=spu_targets)
             if len(spu_targets) > 0
@@ -267,23 +281,21 @@ def upload(
             client.get_mpu_urls(
                 dataset_id=dataset_id,
                 paths=mpu_targets,
-                src_path=source_path.name if expand else None,
+                src_path=src_path.name if expand else None,
             )
             if len(mpu_targets) > 0
             else []
         )
 
-        client.upload_files(
-            dataset_id, spu_url_dicts, mpu_url_dicts, source_path, expand
-        )
+        client.upload_files(dataset_id, spu_url_dicts, mpu_url_dicts, src_path, expand)
 
         files = [
-            get_file_info(url_info["path"], source_path, expand)
+            get_file_info(url_info["path"], src_path, expand)
             for url_info in spu_url_dicts
         ]
         files.extend(
             [
-                get_file_info(url_info["path"], source_path, expand)
+                get_file_info(url_info["path"], src_path, expand)
                 for url_info in mpu_url_dicts
             ]
         )
@@ -322,6 +334,8 @@ def edit(
     """Edit metadata of dataset."""
     project_client: ProjectDataClientService = build_client(ServiceType.PROJECT_DATA)
     dataset_id = project_client.get_id_by_name(name)
+    if dataset_id is None:
+        secho_error_and_exit(f"Dataset with name ({name}) is not found.")
 
     metadata = None
     if metadata_file is not None:
diff --git a/pfcli/deployment.py b/pfcli/deployment.py
index 10b3b0d..42d5b0c 100644
--- a/pfcli/deployment.py
+++ b/pfcli/deployment.py
@@ -22,12 +22,8 @@
 )
 from pfcli.service.config import build_deployment_configurator
 from pfcli.service.formatter import PanelFormatter, TableFormatter
-from pfcli.utils import (
-    datetime_to_pretty_str,
-    secho_error_and_exit,
-    open_editor,
-    get_default_editor,
-)
+from pfcli.utils.format import datetime_to_pretty_str, secho_error_and_exit
+from pfcli.utils.prompt import get_default_editor, open_editor
 
 
 app = typer.Typer(
@@ -45,7 +41,16 @@
 
 deployment_panel = PanelFormatter(
     name="Overview",
-    fields=["id", "config.name", "status", "vms", "config.gpu_type", "config.total_gpus", "start", "endpoint"],
+    fields=[
+        "id",
+        "config.name",
+        "status",
+        "vms",
+        "config.gpu_type",
+        "config.total_gpus",
+        "start",
+        "endpoint",
+    ],
     headers=["ID", "Name", "Status", "VM", "Device", "Device Cnt", "Start", "Endpoint"],
     extra_fields=["error"],
     extra_headers=["error"],
@@ -129,10 +134,7 @@ def delete(deployment_id: str = typer.Argument(..., help="ID of deployment to de
 
 @app.command()
 def view(
-    deployment_id: str = typer.Argument(
-        ...,
-        help="deployment id to inspect detail."
-    )
+    deployment_id: str = typer.Argument(..., help="deployment id to inspect detail.")
 ):
     """Show details of a deployment."""
     client: DeploymentClientService = build_client(ServiceType.DEPLOYMENT)
@@ -151,52 +153,26 @@ def view(
 @app.command()
 def create(
     project_id: str = typer.Option(
-        ...,
-        "--project-id",
-        "-pid",
-        help="Project to deploy."
+        ..., "--project-id", "-pid", help="Project to deploy."
     ),
     checkpoint_id: str = typer.Option(
-        ...,
-        "--checkpoint-id",
-        "-id",
-        help="Checkpoint id to deploy."
+        ..., "--checkpoint-id", "-id", help="Checkpoint id to deploy."
     ),
     deployment_name: str = typer.Option(
-        ...,
-        "--name",
-        "-n",
-        help="The name of deployment. "
+        ..., "--name", "-n", help="The name of deployment. "
     ),
     gpu_type: GpuType = typer.Option(
-        ...,
-        "--gpu-type",
-        "-g",
-        help="The GPU type where the deployment is deployed."
+        ..., "--gpu-type", "-g", help="The GPU type where the deployment is deployed."
     ),
     cloud: CloudType = typer.Option(
-        ...,
-        "--cloud",
-        "-c",
-        help="Type of cloud(aws, azure, gcp)."
-    ),
-    region: str = typer.Option(
-        ...,
-        "--region",
-        "-r",
-        help="Region of cloud."
+        ..., "--cloud", "-c", help="Type of cloud(aws, azure, gcp)."
     ),
+    region: str = typer.Option(..., "--region", "-r", help="Region of cloud."),
     engine: Optional[EngineType] = typer.Option(
-        EngineType.ORCA,
-        "--engine",
-        "-e",
-        help="Type of engine(orca or triton)."
+        EngineType.ORCA, "--engine", "-e", help="Type of engine(orca or triton)."
     ),
     config_file: typer.FileText = typer.Option(
-        ...,
-        "--config-file",
-        "-f",
-        help="Path to configuration file."
+        ..., "--config-file", "-f", help="Path to configuration file."
     ),
 ):
     """Create a deployment object by using model checkpoint."""
diff --git a/pfcli/experiment.py b/pfcli/experiment.py
deleted file mode 100644
index 37134de..0000000
--- a/pfcli/experiment.py
+++ /dev/null
@@ -1,189 +0,0 @@
-# Copyright (C) 2021 FriendliAI
-
-"""PeriFlow Experiment CLI"""
-
-from typing import Optional
-from datetime import datetime
-from dateutil.parser import parse
-
-import typer
-
-from pfcli.service import ServiceType
-from pfcli.service.client import (
-    ExperimentClientService,
-    ProjectExperimentClientService,
-    build_client,
-)
-from pfcli.service.formatter import PanelFormatter, TableFormatter
-from pfcli.utils import datetime_to_pretty_str, timedelta_to_pretty_str
-
-app = typer.Typer(
-    no_args_is_help=True,
-    context_settings={"help_option_names": ["-h", "--help"]},
-    add_completion=False,
-)
-table_formatter = TableFormatter(name="Experiments", fields=["name"], headers=["Name"])
-panel_formatter = PanelFormatter(name="Overview", fields=["name"], headers=["Name"])
-exp_job_table = TableFormatter(
-    name="Jobs",
-    fields=[
-        "id",
-        "project",
-        "name",
-        "status",
-        "vm_config.vm_config_type.code",
-        "vm_config.vm_config_type.device_type",
-        "num_desired_devices",
-        "data_name",
-        "started_at",
-        "duration",
-    ],
-    headers=[
-        "ID",
-        "Project",
-        "Name",
-        "Status",
-        "VM",
-        "Device",
-        "Device Cnt",
-        "Data",
-        "Start",
-        "Duration",
-    ],
-)
-exp_job_table.apply_styling("ID", style="bold")
-exp_job_table.add_substitution_rule("waiting", "[bold]waiting")
-exp_job_table.add_substitution_rule("enqueued", "[bold cyan]enqueued")
-exp_job_table.add_substitution_rule("running", "[bold blue]running")
-exp_job_table.add_substitution_rule("success", "[bold green]success")
-exp_job_table.add_substitution_rule("failed", "[bold red]failed")
-exp_job_table.add_substitution_rule("terminated", "[bold yellow]terminated")
-exp_job_table.add_substitution_rule("terminating", "[bold magenta]terminating")
-exp_job_table.add_substitution_rule("cancelling", "[bold magenta]cancelling")
-exp_job_table.add_substitution_rule("None", "-")
-
-
-@app.command()
-def create(
-    name: str = typer.Option(
-        ..., "--name", "-n", help="The name of experiment to create."
-    )
-):
-    """Create a new experiment."""
-    client: ProjectExperimentClientService = build_client(
-        ServiceType.PROJECT_EXPERIMENT
-    )
-    experiment = client.create_experiment(name)
-    panel_formatter.render([experiment])
-
-
-@app.command()
-def list():
-    """List all experiments."""
-    client: ProjectExperimentClientService = build_client(
-        ServiceType.PROJECT_EXPERIMENT
-    )
-    experiments = client.list_experiments()
-    table_formatter.render(experiments)
-
-
-@app.command()
-def view(
-    name: str = typer.Argument(..., help="The name of experiment to view details."),
-    tail: Optional[int] = typer.Option(
-        None, "--tail", help="The number of job list to view at the tail"
-    ),
-    head: Optional[int] = typer.Option(
-        None, "--head", help="The number of job list to view at the head"
-    ),
-):
-    """Show a list of jobs in the experiment."""
-    client: ExperimentClientService = build_client(ServiceType.EXPERIMENT)
-    project_client: ProjectExperimentClientService = build_client(
-        ServiceType.PROJECT_EXPERIMENT
-    )
-    experiment_id = project_client.get_id_by_name(name)
-    jobs = client.list_jobs_in_experiment(experiment_id)
-
-    for job in jobs:
-        started_at = job.get("started_at")
-        finished_at = job.get("finished_at")
-        if started_at is not None:
-            start = datetime_to_pretty_str(parse(job["started_at"]))
-        else:
-            start = None
-        if started_at is not None and finished_at is not None:
-            duration = timedelta_to_pretty_str(parse(started_at), parse(finished_at))
-        elif started_at is not None and job["status"] == "running":
-            start_time = parse(started_at)
-            curr_time = datetime.now(start_time.tzinfo)
-            duration = timedelta_to_pretty_str(start_time, curr_time)
-        else:
-            duration = None
-        job["started_at"] = start
-        job["duration"] = duration
-        job["data_name"] = (
-            job["data_store"]["name"] if job["data_store"] is not None else None
-        )
-
-    if tail is not None or head is not None:
-        target_job_list = []
-        if tail:
-            target_job_list.extend(jobs[:tail])
-        if head:
-            target_job_list.extend(jobs[-head:])
-    else:
-        target_job_list = jobs
-
-    exp_job_table.render(target_job_list)
-
-
-@app.command()
-def edit(
-    name: str = typer.Argument(..., help="The name of experiment to update."),
-    new_name: str = typer.Option(..., "--name", "-n", help="The new new of experiment"),
-):
-    """Edit experiment info."""
-    client: ExperimentClientService = build_client(ServiceType.EXPERIMENT)
-    project_client: ProjectExperimentClientService = build_client(
-        ServiceType.PROJECT_EXPERIMENT
-    )
-
-    experiment_id = project_client.get_id_by_name(name)
-    experiment = client.update_experiment_name(experiment_id, new_name)
-    panel_formatter.render([experiment])
-
-
-@app.command()
-def delete(
-    name: str = typer.Argument(..., help="The name of experiment to delete."),
-    force: bool = typer.Option(
-        False,
-        "--force",
-        "-f",
-        help="Forcefully delete experiments and all jobs inside the experiment without confirmation prompt.",
-    ),
-):
-    """Delete a experiment."""
-    if not force:
-        do_delete = typer.confirm(
-            "!!! This action is VERY DESTRUCTIVE !!!\n"
-            "Are your sure to delete experiment and ALL THE JOBS inside the experiment?"
-        )
-        if not do_delete:
-            raise typer.Abort()
-        really_do_delete = typer.confirm(
-            "STOP!!! Are you really sure to delete? This is the last confirmation."
-        )
-        if not really_do_delete:
-            raise typer.Abort()
-
-    client: ExperimentClientService = build_client(ServiceType.EXPERIMENT)
-    project_client: ProjectExperimentClientService = build_client(
-        ServiceType.PROJECT_EXPERIMENT
-    )
-
-    experiment_id = project_client.get_id_by_name(name)
-    client.delete_experiment(experiment_id)
-
-    typer.secho(f"Experiment ({name}) is deleted successfully!", fg=typer.colors.BLUE)
diff --git a/pfcli/group.py b/pfcli/group.py
index d585d7f..0ddbaef 100644
--- a/pfcli/group.py
+++ b/pfcli/group.py
@@ -2,24 +2,20 @@
 
 """PeriFlow Group (Organization) CLI"""
 
+from uuid import UUID
+
 import typer
 
-from pfcli.context import (
-    get_current_group_id,
-    get_current_project_id,
-    project_context_path,
-    set_current_group_id,
-)
+from pfcli.context import get_current_group_id
 from pfcli.service import GroupRole, ServiceType
 from pfcli.service.client import (
     GroupClientService,
-    ProjectClientService,
     UserClientService,
     UserGroupClientService,
     build_client,
 )
 from pfcli.service.formatter import PanelFormatter, TableFormatter
-from pfcli.utils import secho_error_and_exit
+from pfcli.utils.format import secho_error_and_exit
 
 
 app = typer.Typer(
@@ -28,7 +24,6 @@
     add_completion=False,
 )
 
-
 org_table_formatter = TableFormatter(
     name="Organization", fields=["name", "id"], headers=["Name", "ID"]
 )
@@ -44,82 +39,11 @@
 )
 
 
-@app.command(help="list all organizations")
-def list():
-    client: UserGroupClientService = build_client(ServiceType.USER_GROUP)
-    orgs = client.get_group_info()
-    current_org_id = get_current_group_id()
-
-    for org in orgs:
-        if current_org_id is not None and org["id"] == str(current_org_id):
-            org["name"] = f"[bold green]* {org['name']}"
-            org["id"] = f"[bold green]{org['id']}"
-        else:
-            org["name"] = f"  {org['name']}"
-
-    org_table_formatter.render(orgs)
-
-
-@app.command(help="create a new organization")
-def create(name: str = typer.Argument(..., help="Name of organization to create")):
-    client: GroupClientService = build_client(ServiceType.GROUP)
-    org = client.create_group(name=name)
-    typer.secho(f"Organization ({name}) is created successfully!", fg=typer.colors.BLUE)
-    org_panel_formatter.render(org)
-
-
-@app.command(help="get current working organization")
-def current():
-    group_client: GroupClientService = build_client(ServiceType.GROUP)
-    org_id = get_current_group_id()
-
-    if org_id is None:
-        secho_error_and_exit("working organization is not set")
-
-    org = group_client.get_group(org_id)
-    org_panel_formatter.render(org)
-
-
-@app.command(help="switch working organization")
-def switch(
-    name: str = typer.Argument(
-        ...,
-        help="Name of organization to switch",
-    )
-):
-    project_client: ProjectClientService = build_client(ServiceType.PROJECT)
-    user_group_client: UserGroupClientService = build_client(ServiceType.USER_GROUP)
-    orgs = user_group_client.get_group_info()
-
-    org_id = None
-    for org in orgs:
-        if org["name"] == name:
-            org_id = org["id"]
-            break
-
-    if org_id is None:
-        secho_error_and_exit(f"No organization exists with name {name}.")
-
-    project_id = get_current_project_id()
-    if project_id is not None:
-        if project_client.check_project_membership(pf_project_id=project_id):
-            project_org_id = project_client.get_project(pf_project_id=project_id)[
-                "pf_group_id"
-            ]
-            if project_org_id != org_id:
-                project_context_path.unlink(missing_ok=True)
-        else:
-            project_context_path.unlink(missing_ok=True)
-
-    set_current_group_id(org_id)
-    typer.secho(f"Organization switched to {name}.", fg=typer.colors.BLUE)
-
-
 @app.command(help="invite to current working organization")
 def invite(email: str = typer.Argument(..., help="Invitation recipient email address")):
     group_client: GroupClientService = build_client(ServiceType.GROUP)
 
-    org = _get_current_org()
+    org = get_current_org()
 
     if org["privilege_level"] != "owner":
         secho_error_and_exit("Only the owner of the organization can invite/set-role.")
@@ -145,7 +69,7 @@ def set_role(
 ):
     user_client: UserClientService = build_client(ServiceType.USER)
 
-    org = _get_current_org()
+    org = get_current_org()
 
     if org["privilege_level"] != "owner":
         secho_error_and_exit(
@@ -159,31 +83,28 @@ def set_role(
     )
 
 
-def _get_org_user_id_by_name(org_id: str, username: str) -> str:
+def _get_org_user_id_by_name(org_id: UUID, username: str) -> UUID:
     group_client: GroupClientService = build_client(ServiceType.GROUP)
-    users = group_client.get_user(org_id, username)
+    users = group_client.get_users(org_id, username)
     for user in users:
         if user["username"] == username:
-            return user["id"]
+            return UUID(user["id"])
     secho_error_and_exit(f"{username} is not a member of this organization.")
 
 
-def _get_current_org() -> dict:
+def get_current_org() -> dict:
     user_group_client: UserGroupClientService = build_client(ServiceType.USER_GROUP)
 
     curr_org_id = get_current_group_id()
     if curr_org_id is None:
-        secho_error_and_exit("working organization is not set")
+        secho_error_and_exit("Organization is not identified. Please login again.")
 
-    orgs = user_group_client.get_group_info()
-    for org in orgs:
-        if org["id"] == str(curr_org_id):
-            return org
+    org = user_group_client.get_group_info()
+    if org["id"] == str(curr_org_id):
+        return org
 
     # org context may be wrong
-    secho_error_and_exit(
-        "Failed to identify organization... Please set organization again."
-    )
+    secho_error_and_exit("Failed to identify organization.")
 
 
 @app.command(help="list up members in the current working organization")
@@ -192,7 +113,7 @@ def members():
     org_id = get_current_group_id()
 
     if org_id is None:
-        secho_error_and_exit("working organization is not set")
+        secho_error_and_exit("Organization is not identified. Please login again.")
 
     members = group_client.list_users(org_id)
     member_table_formatter.render(members)
diff --git a/pfcli/job.py b/pfcli/job.py
index 367f4e4..75bbda0 100644
--- a/pfcli/job.py
+++ b/pfcli/job.py
@@ -7,6 +7,7 @@
 from datetime import datetime
 from pathlib import Path
 from typing import Generator, List, Optional, Tuple
+from uuid import UUID
 
 import ruamel.yaml
 import tabulate
@@ -26,31 +27,30 @@
     storage_type_map_inv,
 )
 from pfcli.service.client import (
-    GroupVMConfigClientService,
-    JobArtifactClientService,
-    JobCheckpointClientService,
-    JobClientService,
+    PFTGroupVMConfigClientService,
     JobTemplateClientService,
     JobWebSocketClientService,
     ProjectDataClientService,
-    ProjectExperimentClientService,
     ProjectJobClientService,
+    UserClientService,
     build_client,
 )
+from pfcli.service.client.job import (
+    ProjectJobArtifactClientService,
+    ProjectJobCheckpointClientService,
+)
 from pfcli.service.client.metrics import MetricsClientService
-from pfcli.service.client.project import ProjectClientService
 from pfcli.service.config import build_job_configurator
 from pfcli.service.formatter import PanelFormatter, TableFormatter
-from pfcli.utils import (
+from pfcli.utils.format import (
     datetime_to_pretty_str,
     datetime_to_simple_string,
-    get_default_editor,
-    open_editor,
     secho_error_and_exit,
     timedelta_to_pretty_str,
     utc_to_local,
-    validate_datetime_format,
 )
+from pfcli.utils.prompt import get_default_editor, open_editor
+from pfcli.utils.validate import validate_datetime_format
 
 tabulate.PRESERVE_WHITESPACE = True
 
@@ -76,7 +76,7 @@
 job_table = TableFormatter(
     name="Jobs",
     fields=[
-        "id",
+        "number",
         "name",
         "status",
         "vm_config.vm_config_type.code",
@@ -89,7 +89,7 @@
         "progress",
     ],
     headers=[
-        "ID",
+        "Number",
         "Name",
         "Status",
         "VM",
@@ -116,6 +116,7 @@
     name="Overview",
     fields=[
         "id",
+        "number",
         "name",
         "status",
         "vm_config.vm_config_type.code",
@@ -129,6 +130,7 @@
     ],
     headers=[
         "ID",
+        "Number",
         "Name",
         "Status",
         "VM",
@@ -177,7 +179,6 @@ def refine_config(
     config: dict,
     vm_name: Optional[str],
     num_devices: Optional[int],
-    experiment_name: Optional[str],
     job_name: Optional[str],
 ) -> None:
     assert "job_setting" in config
@@ -196,34 +197,16 @@ def refine_config(
     ):
         config["job_setting"]["workspace"] = {"mount_path": "/workspace"}
 
-    experiment_client: ProjectExperimentClientService = build_client(
-        ServiceType.PROJECT_EXPERIMENT
-    )
     data_client: ProjectDataClientService = build_client(ServiceType.PROJECT_DATA)
-    vm_client: GroupVMConfigClientService = build_client(ServiceType.GROUP_VM_CONFIG)
+    vm_client: PFTGroupVMConfigClientService = build_client(
+        ServiceType.PFT_GROUP_VM_CONFIG
+    )
     job_template_client: JobTemplateClientService = build_client(
         ServiceType.JOB_TEMPLATE
     )
 
-    experiment_name = experiment_name or config["experiment"]
-    experiment_id = experiment_client.get_id_by_name(experiment_name)
-    if experiment_id is None:
-        create_new = typer.confirm(
-            f"Experiment with the name ({experiment_name}) is not found.\n"
-            "Do you want to proceed with creating a new experiment? "
-            f"If yes, a new experiment will be created with the name: {experiment_name}",
-            prompt_suffix="\n>> ",
-        )
-        if not create_new:
-            typer.echo("Please run the job after creating an experiment first.")
-            raise typer.Abort()
-        experiment_id = experiment_client.create_experiment(experiment_name)["id"]
-        typer.echo(f"A new experiment ({experiment_name}) is created.")
-
-    del config["experiment"]
-    config["experiment_id"] = experiment_id
-
-    vm_name = vm_name or config["vm"]
+    vm_name = vm_name or config.get("vm")
+    assert vm_name is not None
     vm_config_id = vm_client.get_id_by_name(vm_name)
     if vm_config_id is None:
         secho_error_and_exit(f"VM ({vm_name}) is not found.")
@@ -249,18 +232,6 @@ def refine_config(
                     "setup": "",
                     "run": docker_command,
                 }
-    else:
-        job_template_name = config["job_setting"]["template_name"]
-        job_template_config = job_template_client.get_job_template_by_name(
-            job_template_name
-        )
-        if job_template_config is None:
-            secho_error_and_exit(
-                f"Predefined job template ({job_template_name}) is not found."
-            )
-        del config["job_setting"]["template_name"]
-        config["job_setting"]["engine_code"] = job_template_config["engine_code"]
-        config["job_setting"]["model_code"] = job_template_config["model_code"]
 
 
 @app.command()
@@ -274,13 +245,6 @@ def run(
         "-w",
         help="Path to workspace directory in your local file system",
     ),
-    experiment_name: Optional[str] = typer.Option(
-        None,
-        "--experiment",
-        "-e",
-        help="The name of experiment. "
-        "If not provided, the value in the config file will be used.",
-    ),
     job_name: Optional[str] = typer.Option(
         None,
         "--name",
@@ -305,20 +269,20 @@ def run(
     except yaml.YAMLError as e:
         secho_error_and_exit(f"Error occurred while parsing config file... {e}")
 
-    refine_config(config, vm_name, num_devices, experiment_name, job_name)
+    refine_config(config, vm_name, num_devices, job_name)
 
     if workspace_dir is not None:
         workspace_dir = workspace_dir.resolve()  # ensure absolute path
         if not workspace_dir.exists():
-            secho_error_and_exit(f"Specified workspace does not exist...")
+            secho_error_and_exit("Specified workspace does not exist...")
         if not workspace_dir.is_dir():
-            secho_error_and_exit(f"Specified workspace is not directory...")
+            secho_error_and_exit("Specified workspace is not directory...")
 
     client: ProjectJobClientService = build_client(ServiceType.PROJECT_JOB)
     job_data = client.run_job(config, workspace_dir)
 
     typer.secho(
-        f"Job ({job_data['id']}) started successfully. Use 'pf job log {job_data['id']}' to see the job logs.",
+        f"Job ({job_data['number']}) started successfully. Use 'pf job log {job_data['number']}' to see the job logs.",
         fg=typer.colors.BLUE,
     )
 
@@ -331,10 +295,10 @@ def list(
     head: Optional[int] = typer.Option(
         None, "--head", help="The number of job list to view at the head"
     ),
-    show_project_job: bool = typer.Option(
+    show_all: bool = typer.Option(
         False,
-        "--project",
-        "-p",
+        "--all",
+        "-a",
         help="Show all jobs in my project including jobs launched by other users",
     ),
     since: str = typer.Option(
@@ -365,14 +329,16 @@ def list(
         None,
         "--status",
         help="Filter jobs by job status",
-    )
+    ),
 ):
     """List all jobs."""
-    if show_project_job:
-        client: ProjectJobClientService = build_client(ServiceType.PROJECT_JOB)
-    else:
-        client: JobClientService = build_client(ServiceType.JOB)
-    
+    client: ProjectJobClientService = build_client(ServiceType.PROJECT_JOB)
+
+    user_ids = None
+    if not show_all:
+        user_client: UserClientService = build_client(ServiceType.USER)
+        user_ids = [user_client.get_current_user_id()]
+
     real_statuses = job_status_map_inv[status] if status is not None else None
     jobs = client.list_jobs(
         since=since,
@@ -380,6 +346,7 @@ def list(
         job_name=job_name,
         vm=vm,
         statuses=real_statuses,
+        user_ids=user_ids,
     )
 
     for job in jobs:
@@ -420,54 +387,54 @@ def list(
 
 
 @app.command()
-def stop(job_id: int = typer.Argument(..., help="ID of job to stop")):
+def stop(job_number: int = typer.Argument(..., help="Job number to be stopped")):
     """Termiate/cancel a running/enqueued job."""
-    client: JobClientService = build_client(ServiceType.JOB)
-    job_status = client.get_job(job_id)["status"]
+    client: ProjectJobClientService = build_client(ServiceType.PROJECT_JOB)
+    job_status = client.get_job(job_number)["status"]
 
     if job_status == JobStatus.WAITING:
-        client.cancel_job(job_id)
+        client.cancel_job(job_number)
     elif job_status in (
         JobStatus.ENQUEUED,
         JobStatus.STARTED,
         JobStatus.ALLOCATING,
         JobStatus.PREPARING,
-        JobStatus.RUNNING
+        JobStatus.RUNNING,
     ):
-        client.terminate_job(job_id)
+        client.terminate_job(job_number)
     else:
         secho_error_and_exit(f"No need to stop {job_status} job...")
 
 
 @app.command()
 def delete(
-    job_id: int = typer.Argument(..., help="ID of job to delete"),
+    job_number: int = typer.Argument(..., help="Job number to be deleted"),
 ):
     """Delete a job."""
     do_delete = typer.confirm("Are your sure to delete job?")
     if not do_delete:
         raise typer.Abort()
 
-    client: JobClientService = build_client(ServiceType.JOB)
-    client.delete_job(job_id)
+    client: ProjectJobClientService = build_client(ServiceType.PROJECT_JOB)
+    client.delete_job(job_number)
 
-    typer.secho(f"Job ({job_id}) deleted successfully!", fg=typer.colors.BLUE)
+    typer.secho(f"Job ({job_number}) deleted successfully!", fg=typer.colors.BLUE)
 
 
 @app.command()
 def view(
-    job_id: int = typer.Argument(..., help="ID of job to view detail"),
+    job_number: int = typer.Argument(..., help="Job number to view detail"),
 ):
     """Show job detail."""
-    job_client: JobClientService = build_client(ServiceType.JOB)
-    job_checkpoint_client: JobCheckpointClientService = build_client(
-        ServiceType.JOB_CHECKPOINT, job_id=job_id
+    job_client: ProjectJobClientService = build_client(ServiceType.PROJECT_JOB)
+    job_checkpoint_client: ProjectJobCheckpointClientService = build_client(
+        ServiceType.PROJECT_JOB_CHECKPOINT, job_number=job_number
     )
-    job_artifact_client: JobArtifactClientService = build_client(
-        ServiceType.JOB_ARTIFACT, job_id=job_id
+    job_artifact_client: ProjectJobArtifactClientService = build_client(
+        ServiceType.PROJECT_JOB_ARTIFACT, job_number=job_number
     )
 
-    job = job_client.get_job(job_id)
+    job = job_client.get_job(job_number)
     job_checkpoints = job_checkpoint_client.list_checkpoints()
     job_artifacts = job_artifact_client.list_artifacts()
 
@@ -576,7 +543,7 @@ def _format_log_string(
 
 
 async def monitor_logs(
-    job_id: int,
+    job_id: UUID,
     log_types: Optional[List[str]],
     machines: Optional[List[int]],
     show_time: bool,
@@ -598,7 +565,7 @@ async def monitor_logs(
 # TODO: Implement since/until if necessary
 @app.command()
 def log(
-    job_id: int = typer.Argument(..., help="ID of job to view log"),
+    job_number: int = typer.Argument(..., help="Job number to view log"),
     num_records: int = typer.Option(
         100, "--num-records", "-n", help="The number of recent records to view"
     ),
@@ -637,8 +604,15 @@ def log(
     if export_path is not None and follow:
         secho_error_and_exit("'follow' cannot be set when 'export_path' is given")
 
-    client: JobClientService = build_client(ServiceType.JOB)
-    logs = client.get_text_logs(job_id, num_records, head, None, machines, content)
+    client: ProjectJobClientService = build_client(ServiceType.PROJECT_JOB)
+    logs = client.get_text_logs(
+        job_number=job_number,
+        num_records=num_records,
+        head=head,
+        log_types=None,
+        machines=machines,  # type: ignore
+        content=content,
+    )
 
     job_finished = False
     if export_path is not None:
@@ -656,10 +630,18 @@ def log(
                 typer.echo(line, nl=False)
 
     if not job_finished and follow:
+        job_client: ProjectJobClientService = build_client(ServiceType.PROJECT_JOB)
+        job_id = UUID(job_client.get_job(job_number)["id"])
         try:
             # Subscribe job log
             asyncio.run(
-                monitor_logs(job_id, None, machines, show_time, show_machine_id)
+                monitor_logs(
+                    job_id=job_id,
+                    log_types=None,
+                    machines=machines,  # type: ignore
+                    show_time=show_time,
+                    show_machine_id=show_machine_id,
+                )
             )
         except KeyboardInterrupt:
             secho_error_and_exit(f"Keyboard Interrupt...", color=typer.colors.MAGENTA)
@@ -667,9 +649,13 @@ def log(
 
 @metrics_app.command("list")
 def metrics_list(
-    job_id: int = typer.Argument(..., help="ID of job"),
+    job_number: int = typer.Argument(..., help="Job number"),
 ):
     """Show available metrics"""
+    job_client: ProjectJobClientService = build_client(ServiceType.PROJECT_JOB)
+    job = job_client.get_job(job_number)
+    job_id = job["id"]
+
     client: MetricsClientService = build_client(ServiceType.METRICS)
     results = client.list_metrics(job_id=job_id)
     metrics_list_table.render(results)
@@ -677,7 +663,7 @@ def metrics_list(
 
 @metrics_app.command("show")
 def show_metrics(
-    job_id: int = typer.Argument(..., help="ID of job"),
+    job_number: int = typer.Argument(..., help="Job number"),
     name: List[str] = typer.Option(..., help="metrics name"),
     limit: int = typer.Option(10, help="Number of metrics to show"),
 ):
@@ -686,12 +672,15 @@ def show_metrics(
         secho_error_and_exit(
             "'limit' should be a positive integer, equal or smaller than 1000"
         )
+    job_client: ProjectJobClientService = build_client(ServiceType.PROJECT_JOB)
+    job = job_client.get_job(job_number)
+    job_id = job["id"]
 
     client: MetricsClientService = build_client(ServiceType.METRICS)
     metrics_set = []
     for metric_name in name:
         metrics = client.get_metrics_values(job_id, metric_name, limit)
-        if not len(metrics):
+        if not metrics:
             secho_error_and_exit(
                 f"There is no available metrics with name '{metric_name}'."
             )
diff --git a/pfcli/main.py b/pfcli/main.py
index bbdd1bd..77c33d1 100644
--- a/pfcli/main.py
+++ b/pfcli/main.py
@@ -13,24 +13,30 @@
     checkpoint,
     credential,
     dataset,
-    experiment,
     group,
     job,
     project,
     deployment,
     vm,
 )
+from pfcli.context import (
+    get_current_project_id,
+    project_context_path,
+    set_current_group_id,
+)
 from pfcli.service import ServiceType
 from pfcli.service.auth import clear_tokens, get_token, TokenType, update_token
 from pfcli.service.client import (
+    build_client,
+    ProjectClientService,
     UserClientService,
+    UserGroupClientService,
     UserMFAService,
     UserSignUpService,
-    build_client,
 )
 from pfcli.service.formatter import PanelFormatter
-from pfcli.utils import get_uri, secho_error_and_exit
-from pfcli.settings import settings
+from pfcli.utils.format import secho_error_and_exit
+from pfcli.utils.url import get_uri
 
 app = typer.Typer(
     help="Welcome to PeriFlow 🤗",
@@ -42,16 +48,13 @@
 app.add_typer(credential.app, name="credential", help="Manage credentials")
 app.add_typer(checkpoint.app, name="checkpoint", help="Manage checkpoints")
 app.add_typer(vm.app, name="vm", help="Manage VMs")
-app.add_typer(experiment.app, name="experiment", help="Manage experiments")
 app.add_typer(deployment.app, name="deployment", help="Manage deployments")
 app.add_typer(project.app, name="project", help="Manage projects")
 app.add_typer(group.app, name="org", help="Manage organizations")
 app.add_typer(billing.app, name="billing", help="Manage billing")
-
-if not settings.pfs_only:
-    app.add_typer(artifact.app, name="artifact", help="Manager artifacts")
-    app.add_typer(job.app, name="job", help="Manage jobs")
-    app.add_typer(dataset.app, name="dataset", help="Manage datasets")
+app.add_typer(artifact.app, name="artifact", help="Manager artifacts")
+app.add_typer(job.app, name="job", help="Manage jobs")
+app.add_typer(dataset.app, name="dataset", help="Manage datasets")
 
 
 user_panel_formatter = PanelFormatter(
@@ -107,6 +110,25 @@ def login(
     else:
         _handle_login_response(r, False)
 
+    # Save user's organiztion context
+    project_client: ProjectClientService = build_client(ServiceType.PROJECT)
+    user_group_client: UserGroupClientService = build_client(ServiceType.USER_GROUP)
+
+    org = user_group_client.get_group_info()
+    org_id = org["id"]
+
+    project_id = get_current_project_id()
+    if project_id is not None:
+        if project_client.check_project_membership(pf_project_id=project_id):
+            project_org_id = project_client.get_project(pf_project_id=project_id)[
+                "pf_group_id"
+            ]
+            if project_org_id != org_id:
+                project_context_path.unlink(missing_ok=True)
+        else:
+            project_context_path.unlink(missing_ok=True)
+    set_current_group_id(org_id)
+
 
 @app.command(help="Sign out PeriFlow")
 def logout():
@@ -166,10 +188,10 @@ def _handle_login_response(r: Response, mfa: bool):
         typer.echo("\n\nLogin success!")
         typer.echo("Welcome back to...")
         typer.echo(" _____          _  _____ _")
-        typer.echo("|  __ \___ _ __(_)|  ___| | _____      __")
-        typer.echo("|  ___/ _ \ '__| || |__ | |/ _ \ \ /\ / /")
+        typer.echo("|  __ \___ _ __(_)|  ___| | _____      __")  # type: ignore
+        typer.echo("|  ___/ _ \ '__| || |__ | |/ _ \ \ /\ / /")  # type: ignore
         typer.echo("| |  |  __/ |  | ||  __|| | (_) | V  V / ")
-        typer.echo("|_|   \___|_|  |_||_|   |_|\___/ \_/\_/  ")
+        typer.echo("|_|   \___|_|  |_||_|   |_|\___/ \_/\_/  ")  # type: ignore
         typer.echo("\n\n")
     except HTTPError:
         if mfa:
diff --git a/pfcli/project.py b/pfcli/project.py
index b80386d..2dee880 100644
--- a/pfcli/project.py
+++ b/pfcli/project.py
@@ -2,8 +2,8 @@
 
 """PeriFlow Project CLI"""
 
-import uuid
-from typing import List, Optional, Tuple, Union
+from typing import Optional, Tuple, Union
+from uuid import UUID
 
 import typer
 
@@ -12,7 +12,7 @@
     set_current_project_id,
     project_context_path,
 )
-from pfcli.group import _get_current_org, _get_org_user_id_by_name
+from pfcli.group import get_current_org, _get_org_user_id_by_name
 from pfcli.service import GroupRole, ProjectRole, ServiceType
 from pfcli.service.client import (
     GroupProjectClientService,
@@ -21,8 +21,9 @@
     UserGroupProjectClientService,
     build_client,
 )
+from pfcli.service.client.project import find_project_id
 from pfcli.service.formatter import PanelFormatter, TableFormatter
-from pfcli.utils import secho_error_and_exit
+from pfcli.utils.format import secho_error_and_exit
 
 
 app = typer.Typer(
@@ -45,13 +46,6 @@
 )
 
 
-def _find_project_id(projects: List[dict], project_name: str) -> uuid.UUID:
-    for project in projects:
-        if project["name"] == project_name:
-            return uuid.UUID(project["id"])
-    secho_error_and_exit(f"No project exists with name {project_name}.")
-
-
 @app.command(help="list all accessible projects")
 def list(
     tail: Optional[int] = typer.Option(
@@ -121,7 +115,7 @@ def switch(
     )
     project_client: ProjectClientService = build_client(ServiceType.PROJECT)
 
-    project_id = _find_project_id(user_group_project_client.list_projects(), name)
+    project_id = find_project_id(user_group_project_client.list_projects(), name)
     if project_client.check_project_membership(pf_project_id=project_id):
         set_current_project_id(project_id)
         typer.secho(f"Project switched to {name}.", fg=typer.colors.BLUE)
@@ -142,13 +136,33 @@ def delete(
     user_group_project_client: UserGroupProjectClientService = build_client(
         ServiceType.USER_GROUP_PROJECT
     )
-    project_id = _find_project_id(user_group_project_client.list_projects(), name)
+    project_id = find_project_id(user_group_project_client.list_projects(), name)
     project_client.delete_project(pf_project_id=project_id)
     if project_id == get_current_project_id():
         project_context_path.unlink()
     typer.secho(f"Project {name} deleted.", fg=typer.colors.BLUE)
 
 
+def _check_project_and_get_id() -> Tuple[UUID, UUID]:
+    """Get org_id and project_id if valid"""
+
+    user_client: UserClientService = build_client(ServiceType.USER)
+
+    org = get_current_org()
+    project_id = get_current_project_id()
+    if project_id is None:
+        secho_error_and_exit("Failed to identify project... Please set project again.")
+
+    if org["privilege_level"] == GroupRole.OWNER:
+        return UUID(org["id"]), project_id
+
+    requester = user_client.get_project_membership(project_id)
+    if requester["access_level"] != ProjectRole.ADMIN:
+        secho_error_and_exit("Only the admin of the project can add-user/set-role")
+
+    return org["id"], project_id
+
+
 @app.command("add-user", help="add user to project")
 def add_user(
     username: str = typer.Argument(
@@ -196,23 +210,8 @@ def members():
     project_client: ProjectClientService = build_client(ServiceType.PROJECT)
 
     project_id = get_current_project_id()
+    if project_id is None:
+        secho_error_and_exit("Failed to identify project... Please set project again.")
+
     members = project_client.list_users(project_id)
     member_table_formatter.render(members)
-
-
-def _check_project_and_get_id() -> Tuple[str, str]:
-    """Get org_id and project_id if valid"""
-
-    user_client: UserClientService = build_client(ServiceType.USER)
-
-    org = _get_current_org()
-    project_id = get_current_project_id()
-
-    if org["privilege_level"] == GroupRole.OWNER:
-        return org["id"], project_id
-
-    requester = user_client.get_project_membership(project_id)
-    if requester["access_level"] != ProjectRole.ADMIN:
-        secho_error_and_exit("Only the admin of the project can add-user/set-role")
-
-    return org["id"], project_id
diff --git a/pfcli/service/__init__.py b/pfcli/service/__init__.py
index f664d8b..1d818cc 100644
--- a/pfcli/service/__init__.py
+++ b/pfcli/service/__init__.py
@@ -13,16 +13,14 @@ class ServiceType(str, Enum):
     PROJECT = "PROJECT"
     GROUP = "GROUP"
     GROUP_PROJECT = "GROUP_PROJECT"
-    EXPERIMENT = "EXPERIMENT"
-    PROJECT_EXPERIMENT = "PROJECT_EXPERIMENT"
-    JOB = "JOB"
-    JOB_CHECKPOINT = "JOB_CHECKPOINT"
-    JOB_ARTIFACT = "JOB_ARTIFACT"
+    PROJECT_JOB_CHECKPOINT = "PROJECT_JOB_CHECKPOINT"
+    PROJECT_JOB_ARTIFACT = "PROJECT_JOB_ARTIFACT"
     PROJECT_JOB = "PROJECT_JOB"
     JOB_TEMPLATE = "JOB_TEMPLATE"
-    PROJECT_VM_QUOTA = "PROJECT_VM_QUOTA"
-    PROJECT_VM_CONFIG = "PROJECT_VM_CONFIG"
-    GROUP_VM_CONFIG = "GROUP_VM_CONFIG"
+    GROUP_VM_QUOTA = "GROUP_VM_QUOTA"
+    PFT_PROJECT_VM_QUOTA = "PROJECT_VM_QUOTA"
+    PFT_PROJECT_VM_CONFIG = "PROJECT_VM_CONFIG"
+    PFT_GROUP_VM_CONFIG = "GROUP_VM_CONFIG"
     CREDENTIAL = "CREDENTIAL"
     PROJECT_CREDENTIAL = "PROJECT_CREDENTIAL"
     CREDENTIAL_TYPE = "CREDENTIAL_TYPE"
@@ -32,7 +30,7 @@ class ServiceType(str, Enum):
     GROUP_PROJECT_CHECKPOINT = "GROUP_PROJECT_CHECKPOINT"
     JOB_WS = "JOB_WS"
     DEPLOYMENT = "DEPLOYMENT"
-    BILLING_SUMMARY = "BILLING_SUMMARY"
+    PFT_BILLING_SUMMARY = "BILLING_SUMMARY"
     METRICS = "METRICS"
 
 
@@ -65,6 +63,7 @@ class JobStatus(str, Enum):
 
 class SimpleJobStatus(str, Enum):
     """Simplified job status delivered to users"""
+
     WAITING = "waiting"
     ALLOCATING = "allocating"
     PREPARING = "preparing"
@@ -91,7 +90,7 @@ class SimpleJobStatus(str, Enum):
 }
 
 
-job_status_map_inv: Dict[SimpleJobStatus, Tuple[JobStatus]] = {
+job_status_map_inv: Dict[SimpleJobStatus, Tuple[JobStatus, ...]] = {
     SimpleJobStatus.WAITING: (
         JobStatus.WAITING,
         JobStatus.ENQUEUED,
@@ -102,11 +101,22 @@ class SimpleJobStatus(str, Enum):
     SimpleJobStatus.RUNNING: (JobStatus.RUNNING,),
     SimpleJobStatus.SUCCESS: (JobStatus.SUCCESS,),
     SimpleJobStatus.FAILED: (JobStatus.FAILED,),
-    SimpleJobStatus.STOPPING: (JobStatus.TERMINATING, JobStatus.CANCELLING,),
-    SimpleJobStatus.STOPPED: (JobStatus.TERMINATED, JobStatus.CANCELLED,),
+    SimpleJobStatus.STOPPING: (
+        JobStatus.TERMINATING,
+        JobStatus.CANCELLING,
+    ),
+    SimpleJobStatus.STOPPED: (
+        JobStatus.TERMINATED,
+        JobStatus.CANCELLED,
+    ),
 }
 
 
+class PeriFlowService(str, Enum):
+    TRAIN = "train"
+    SERVE = "serve"
+
+
 class JobType(str, Enum):
     PREDEFINED = "predefined"
     CUSTOM = "custom"
@@ -162,7 +172,7 @@ class EngineType(str, Enum):
 }
 
 
-storage_type_map_inv: Dict[StorageType, str] = {
+storage_type_map_inv: Dict[str, StorageType] = {
     "aws": StorageType.S3,
     "azure.blob": StorageType.BLOB,
     "gcp": StorageType.GCS,
@@ -194,7 +204,7 @@ class CredType(str, Enum):
 }
 
 
-cred_type_map_inv: Dict[CredType, str] = {
+cred_type_map_inv: Dict[str, CredType] = {
     "docker": CredType.DOCKER,
     "aws": CredType.S3,
     "azure.blob": CredType.BLOB,
diff --git a/pfcli/service/auth.py b/pfcli/service/auth.py
index f451373..3d9bb66 100644
--- a/pfcli/service/auth.py
+++ b/pfcli/service/auth.py
@@ -8,7 +8,9 @@
 
 import requests
 
-from pfcli.utils import get_periflow_directory, get_uri, secho_error_and_exit
+from pfcli.utils.format import secho_error_and_exit
+from pfcli.utils.fs import get_periflow_directory
+from pfcli.utils.url import get_uri
 
 
 access_token_path = get_periflow_directory() / "access_token"
diff --git a/pfcli/service/client/__init__.py b/pfcli/service/client/__init__.py
index adf2f6f..8ab08ce 100644
--- a/pfcli/service/client/__init__.py
+++ b/pfcli/service/client/__init__.py
@@ -5,36 +5,34 @@
 
 from pfcli.service import ServiceType
 from pfcli.service.client.base import ClientService
-from pfcli.service.client.billing import BillingClientService
+from pfcli.service.client.billing import PFTBillingClientService
 from pfcli.service.client.checkpoint import CheckpointClientService
 from pfcli.service.client.credential import (
     CredentialClientService,
     CredentialTypeClientService,
 )
 from pfcli.service.client.data import DataClientService
-from pfcli.service.client.experiment import ExperimentClientService
 from pfcli.service.client.group import (
     GroupClientService,
     GroupProjectCheckpointClientService,
     GroupProjectClientService,
-    GroupVMConfigClientService,
+    GroupProjectVMQuotaClientService,
+    PFTGroupVMConfigClientService,
 )
 from pfcli.service.client.job import (
-    JobArtifactClientService,
-    JobCheckpointClientService,
-    JobClientService,
     JobTemplateClientService,
     JobWebSocketClientService,
+    ProjectJobArtifactClientService,
+    ProjectJobCheckpointClientService,
+    ProjectJobClientService,
 )
 from pfcli.service.client.metrics import MetricsClientService
 from pfcli.service.client.project import (
     ProjectClientService,
     ProjectCredentialClientService,
     ProjectDataClientService,
-    ProjectExperimentClientService,
-    ProjectJobClientService,
-    ProjectVMConfigClientService,
-    ProjectVMQuotaClientService,
+    PFTProjectVMConfigClientService,
+    PFTProjectVMQuotaClientService,
 )
 from pfcli.service.client.deployment import DeploymentClientService
 from pfcli.service.client.user import (
@@ -44,7 +42,7 @@
     UserMFAService,
     UserSignUpService,
 )
-from pfcli.utils import (
+from pfcli.utils.url import (
     get_auth_uri,
     get_meter_uri,
     get_mr_uri,
@@ -53,7 +51,6 @@
     get_uri,
     get_wss_uri,
 )
-from pfcli.settings import settings
 
 client_template_map: Dict[ServiceType, Tuple[Type[ClientService], Template]] = {
     ServiceType.MFA: (
@@ -79,19 +76,13 @@
         GroupProjectClientService,
         Template(get_auth_uri("pf_group/$pf_group_id/pf_project")),
     ),
-    ServiceType.EXPERIMENT: (ExperimentClientService, Template(get_uri("experiment/"))),
-    ServiceType.PROJECT_EXPERIMENT: (
-        ProjectExperimentClientService,
-        Template(get_uri("project/$project_id/experiment/")),
-    ),  # pylint: disable=line-too-long
-    ServiceType.JOB: (JobClientService, Template(get_uri("job/"))),
-    ServiceType.JOB_CHECKPOINT: (
-        JobCheckpointClientService,
-        Template(get_uri("job/$job_id/checkpoint/")),
+    ServiceType.PROJECT_JOB_CHECKPOINT: (
+        ProjectJobCheckpointClientService,
+        Template(get_uri("project/$project_id/job/$job_number/checkpoint/")),
     ),
-    ServiceType.JOB_ARTIFACT: (
-        JobArtifactClientService,
-        Template(get_uri("job/$job_id/artifact/")),
+    ServiceType.PROJECT_JOB_ARTIFACT: (
+        ProjectJobArtifactClientService,
+        Template(get_uri("project/$project_id/job/$job_number/artifact/")),
     ),
     ServiceType.PROJECT_JOB: (
         ProjectJobClientService,
@@ -118,36 +109,35 @@
         ProjectDataClientService,
         Template(get_uri("project/$project_id/datastore/")),
     ),
-    ServiceType.PROJECT_VM_QUOTA: (
-        ProjectVMQuotaClientService,
-        Template(get_uri("project/$project_id/vm_quota/"))
-        if not settings.pfs_only
-        else Template(get_pfs_uri("project/$project_id/vm_quota/")),  # FIXME: fix this path after vm impl in pfs
+    ServiceType.GROUP_VM_QUOTA: (
+        GroupProjectVMQuotaClientService,
+        Template(get_uri("group/$group_id/vm_quota/")),
+    ),
+    ServiceType.PFT_PROJECT_VM_QUOTA: (
+        PFTProjectVMQuotaClientService,
+        Template(get_uri("project/$project_id/vm_quota/")),
     ),
     ServiceType.CHECKPOINT: (CheckpointClientService, Template(get_mr_uri("models/"))),
     ServiceType.GROUP_PROJECT_CHECKPOINT: (
         GroupProjectCheckpointClientService,
         Template(get_mr_uri("orgs/$group_id/prjs/$project_id/models/")),
     ),  # pylint: disable=line-too-long
-    ServiceType.PROJECT_VM_CONFIG: (
-        ProjectVMConfigClientService,
-        Template(get_uri("project/$project_id/vm_config/"))
-        if not settings.pfs_only
-        else Template(get_pfs_uri("project/$project_id/vm_config/")),  # FIXME: fix this path after vm impl in pfs
-    ),
-    ServiceType.GROUP_VM_CONFIG: (
-        GroupVMConfigClientService,
-        Template(get_uri("group/$group_id/vm_config/"))
-        if not settings.pfs_only
-        else Template(get_pfs_uri("group/$group_id/vm_config/")),  # FIXME: fix this path after vm impl in pfs
+    ServiceType.PFT_PROJECT_VM_CONFIG: (
+        PFTProjectVMConfigClientService,
+        Template(get_uri("project/$project_id/vm_config/")),
+    ),
+    ServiceType.PFT_GROUP_VM_CONFIG: (
+        PFTGroupVMConfigClientService,
+        Template(get_uri("group/$group_id/vm_config/")),
     ),
     ServiceType.JOB_WS: (JobWebSocketClientService, Template(get_wss_uri("job/"))),
-    ServiceType.DEPLOYMENT: (DeploymentClientService, Template(get_pfs_uri("deployment/"))),
-    ServiceType.BILLING_SUMMARY: (
-        BillingClientService,
-        Template(get_meter_uri("training/instances/price/")) 
-        if not settings.pfs_only
-        else Template(get_meter_uri("serving/instances/price/")),
+    ServiceType.DEPLOYMENT: (
+        DeploymentClientService,
+        Template(get_pfs_uri("deployment/")),
+    ),
+    ServiceType.PFT_BILLING_SUMMARY: (
+        PFTBillingClientService,
+        Template(get_meter_uri("training/instances/price/")),
     ),
     ServiceType.METRICS: (
         MetricsClientService,
diff --git a/pfcli/service/client/base.py b/pfcli/service/client/base.py
index 12d9157..146c186 100644
--- a/pfcli/service/client/base.py
+++ b/pfcli/service/client/base.py
@@ -6,33 +6,32 @@
 import typer
 import requests
 from dataclasses import dataclass
-from functools import partial, wraps
+from functools import wraps
 from requests.models import Response
 from string import Template
-from typing import Callable, Optional, TypeVar, Union
+from typing import Callable, Generic, Optional, TypeVar, Union
 from urllib.parse import urljoin, urlparse
 
 import uuid
 
 from pfcli.context import get_current_group_id, get_current_project_id
 from pfcli.service.auth import auto_token_refresh, get_auth_header
-from pfcli.utils import decode_http_err, get_auth_uri, secho_error_and_exit
+from pfcli.utils.format import secho_error_and_exit
+from pfcli.utils.request import decode_http_err
+from pfcli.utils.url import get_auth_uri
 
 
 T = TypeVar("T", bound=Union[int, str, uuid.UUID])
 
 
 def safe_request(
-    func: Optional[Callable[..., Response]] = None, *, err_prefix: str = ""
+    func: Callable[..., Response], *, err_prefix: str = ""
 ) -> Callable[..., Response]:
     if err_prefix:
         err_prefix = err_prefix.rstrip() + "\n"
 
-    if func is None:
-        return partial(safe_request, err_prefix=err_prefix)
-
     @wraps(func)
-    def wrapper(*args, **kwargs):
+    def wrapper(*args, **kwargs) -> Response:
         try:
             return func(*args, **kwargs)
         except requests.HTTPError as exc:
@@ -89,7 +88,7 @@ def copy(self) -> "URLTemplate":
         return URLTemplate(pattern=Template(self.pattern.template))
 
 
-class ClientService:
+class ClientService(Generic[T]):
     def __init__(self, template: Template, **kwargs):
         self.url_template = URLTemplate(template)
         self.url_kwargs = kwargs
diff --git a/pfcli/service/client/billing.py b/pfcli/service/client/billing.py
index 8c83378..99a15a6 100644
--- a/pfcli/service/client/billing.py
+++ b/pfcli/service/client/billing.py
@@ -1,28 +1,49 @@
 # Copyright (C) 2022 FriendliAI
 
-from typing import List
+from enum import Enum
+from string import Template
+from typing import List, Optional
 
-from pfcli.service.client.base import ClientService, T, safe_request
-from pfcli.utils import paginated_get
+from pfcli.service.client.base import (
+    ClientService,
+    GroupRequestMixin,
+    ProjectRequestMixin,
+    UserRequestMixin,
+    safe_request,
+)
 
 
-class BillingClientService(ClientService):
+class TimeGranularity(str, Enum):
+    hour = "hour"
+    day = "day"
+    week = "week"
+
+
+class PFTBillingClientService(
+    ClientService, GroupRequestMixin, ProjectRequestMixin, UserRequestMixin
+):
+    def __init__(self, template: Template, **kwargs):
+        self.initialize_group()
+        self.initialize_project()
+        self.initialize_user()
+        super().__init__(template, **kwargs)
+
     def list_prices(
         self,
-        year: int,
-        month: int,
-        day: int = None,
-        group_id: T = None,
-        project_id: T = None,
+        start_date: str,
+        end_date: str,
+        agg_by: str = "user_id",
+        time_granularity: Optional[TimeGranularity] = None,
     ) -> List[dict]:
         params = {
-            "group_id": str(group_id) if group_id else None,
-            "project_id": str(project_id) if project_id else None,
-            "year": year,
-            "month": month,
-            "day": day,
+            "organization_id": str(self.group_id),
+            "project_id": str(self.project_id),
+            "user_id": str(self.user_id),
+            "start_date": start_date,
+            "end_date": end_date,
+            "time_unit": time_granularity,
+            "agg_by": agg_by,
         }
-        get_response_dict = safe_request(
-            self.list, err_prefix=f"Failed to get billing summary."
-        )
-        return paginated_get(get_response_dict, **params)
+        return safe_request(self.list, err_prefix=f"Failed to get billing summary.")(
+            params=params
+        ).json()["results"]
diff --git a/pfcli/service/client/checkpoint.py b/pfcli/service/client/checkpoint.py
index 91e3b84..8da610e 100644
--- a/pfcli/service/client/checkpoint.py
+++ b/pfcli/service/client/checkpoint.py
@@ -3,17 +3,18 @@
 """PeriFlow CheckpointClient Service"""
 
 from typing import List, Optional
+from uuid import UUID
 
 import requests
 from requests.models import Response
 
 from pfcli.service import StorageType
 from pfcli.service.auth import auto_token_refresh, get_auth_header
-from pfcli.service.client.base import ClientService, T, safe_request
+from pfcli.service.client.base import ClientService, safe_request
 
 
-class CheckpointClientService(ClientService):
-    def get_checkpoint(self, checkpoint_id: T) -> dict:
+class CheckpointClientService(ClientService[UUID]):
+    def get_checkpoint(self, checkpoint_id: UUID) -> dict:
         response = safe_request(
             self.retrieve, err_prefix="Failed to get info of checkpoint"
         )(pk=checkpoint_id)
@@ -21,7 +22,7 @@ def get_checkpoint(self, checkpoint_id: T) -> dict:
 
     def update_checkpoint(
         self,
-        checkpoint_id: T,
+        checkpoint_id: UUID,
         *,
         vendor: Optional[StorageType] = None,
         region: Optional[str] = None,
@@ -58,14 +59,14 @@ def update_checkpoint(
         )(pk=checkpoint_id, json=request_data)
         return response.json()
 
-    def delete_checkpoint(self, checkpoint_id: T) -> None:
+    def delete_checkpoint(self, checkpoint_id: UUID) -> Response:
         response = safe_request(self.delete, err_prefix="Failed to delete checkpoint.")(
             pk=checkpoint_id
         )
         return response
 
     @auto_token_refresh
-    def download(self, checkpoint_id: T) -> Response:
+    def download(self, checkpoint_id: UUID) -> Response:
         response = safe_request(
             self.retrieve, err_prefix="Failed to get info of checkpoint."
         )(pk=checkpoint_id)
@@ -78,7 +79,7 @@ def download(self, checkpoint_id: T) -> Response:
             headers=get_auth_header(),
         )
 
-    def get_checkpoint_download_urls(self, checkpoint_id: T) -> List[dict]:
+    def get_checkpoint_download_urls(self, checkpoint_id: UUID) -> List[dict]:
         response = safe_request(
             self.download, err_prefix="Failed to get download URLs of checkpoint files."
         )(checkpoint_id=checkpoint_id)
diff --git a/pfcli/service/client/credential.py b/pfcli/service/client/credential.py
index a4f60ff..65ca781 100644
--- a/pfcli/service/client/credential.py
+++ b/pfcli/service/client/credential.py
@@ -4,13 +4,14 @@
 
 
 from typing import Optional
+from uuid import UUID
 
 from pfcli.service import CredType, cred_type_map
-from pfcli.service.client.base import ClientService, T, safe_request
+from pfcli.service.client.base import ClientService, safe_request
 
 
-class CredentialClientService(ClientService):
-    def get_credential(self, credential_id: T) -> dict:
+class CredentialClientService(ClientService[UUID]):
+    def get_credential(self, credential_id: UUID) -> dict:
         response = safe_request(self.retrieve, err_prefix="Credential is not found.")(
             pk=credential_id
         )
@@ -18,7 +19,7 @@ def get_credential(self, credential_id: T) -> dict:
 
     def update_credential(
         self,
-        credential_id: T,
+        credential_id: UUID,
         *,
         name: Optional[str] = None,
         type_version: Optional[str] = None,
@@ -36,7 +37,7 @@ def update_credential(
         )(pk=credential_id, json=request_data)
         return response.json()
 
-    def delete_credential(self, credential_id: T) -> None:
+    def delete_credential(self, credential_id: UUID) -> None:
         safe_request(self.delete, err_prefix="Failed to delete credential")(
             pk=credential_id
         )
diff --git a/pfcli/service/client/data.py b/pfcli/service/client/data.py
index 9367bc6..8e4de0e 100644
--- a/pfcli/service/client/data.py
+++ b/pfcli/service/client/data.py
@@ -16,12 +16,10 @@
 from tqdm.utils import CallbackIOWrapper
 
 from pfcli.service import StorageType, storage_type_map_inv
-from pfcli.service.client.base import ClientService, T, safe_request
-from pfcli.utils import (
-    secho_error_and_exit,
-    storage_path_to_local_path,
-    validate_storage_region,
-)
+from pfcli.service.client.base import ClientService, safe_request
+from pfcli.utils.format import secho_error_and_exit
+from pfcli.utils.fs import storage_path_to_local_path
+from pfcli.utils.validate import validate_storage_region
 
 # The actual hard limit of a part size is 5 GiB, and we use 200 MiB part size.
 # See https://docs.aws.amazon.com/AmazonS3/latest/userguide/qfacts.html.
@@ -30,7 +28,7 @@
 
 
 class DataClientService(ClientService):
-    def get_dataset(self, dataset_id: T) -> dict:
+    def get_dataset(self, dataset_id: int) -> dict:
         response = safe_request(
             self.retrieve, err_prefix=f"Dataset ({dataset_id}) is not found."
         )(pk=dataset_id)
@@ -38,7 +36,7 @@ def get_dataset(self, dataset_id: T) -> dict:
 
     def update_dataset(
         self,
-        dataset_id: T,
+        dataset_id: int,
         *,
         name: Optional[str] = None,
         vendor: Optional[StorageType] = None,
@@ -79,10 +77,10 @@ def update_dataset(
         )(pk=dataset_id, json=request_data)
         return response.json()
 
-    def delete_dataset(self, dataset_id: T) -> None:
+    def delete_dataset(self, dataset_id: int) -> None:
         safe_request(self.delete, err_prefix="Failed to delete dataset")(pk=dataset_id)
 
-    def get_spu_urls(self, dataset_id: T, paths: List[str]) -> List[dict]:
+    def get_spu_urls(self, dataset_id: int, paths: List[str]) -> List[dict]:
         """Get single part upload URLs for multiple files.
 
         Args:
@@ -98,7 +96,7 @@ def get_spu_urls(self, dataset_id: T, paths: List[str]) -> List[dict]:
         return response.json()
 
     def get_mpu_urls(
-        self, dataset_id: T, paths: List[str], src_path: Optional[str] = None
+        self, dataset_id: int, paths: List[str], src_path: Optional[str] = None
     ) -> List[dict]:
         """Get multipart upload URLs for multiple datasets
 
@@ -141,7 +139,7 @@ def get_mpu_urls(
         return start_mpu_resps
 
     def complete_mpu(
-        self, dataset_id: T, path: str, upload_id: str, parts: List[dict]
+        self, dataset_id: int, path: str, upload_id: str, parts: List[dict]
     ) -> None:
         safe_request(
             self.post, err_prefix=f"Failed to complete multipart upload for {path}"
@@ -154,7 +152,7 @@ def complete_mpu(
             },
         )
 
-    def abort_mpu(self, dataset_id: T, path: str, upload_id: str) -> None:
+    def abort_mpu(self, dataset_id: int, path: str, upload_id: str) -> None:
         safe_request(
             self.post, err_prefix=f"Failed to abort multipart upload for {path}"
         )(
@@ -166,7 +164,7 @@ def abort_mpu(self, dataset_id: T, path: str, upload_id: str) -> None:
         )
 
     def _multipart_upload_file(
-        self, dataset_id: T, file_path: str, url_dict: List[dict], ctx: tqdm
+        self, dataset_id: int, file_path: str, url_dict: dict, ctx: tqdm
     ) -> None:
         # TODO (ym): parallelize each part upload.
         parts = []
@@ -187,7 +185,7 @@ def _multipart_upload_file(
                     s = Session()
                     req = Request("PUT", url_info["upload_url"], data=wrapped_object)
                     prep = req.prepare()
-                    prep.headers["Content-Length"] = chunk_size
+                    prep.headers["Content-Length"] = str(chunk_size)
                     response = s.send(prep)
                     response.raise_for_status()
 
@@ -208,7 +206,7 @@ def _multipart_upload_file(
 
     def upload_files(
         self,
-        dataset_id: T,
+        dataset_id: int,
         spu_url_dicts: List[Dict[str, str]],
         mpu_url_dicts: List[dict],
         source_path: Path,
@@ -282,10 +280,10 @@ def expand_paths(path: Path, expand: bool, size_type: FileSizeType) -> List[str]
         paths = [str(path.name)]
         paths = filter_files_by_size(paths, size_type)
     else:
-        paths = list(path.rglob("*"))
+        paths = [str(p) for p in path.rglob("*")]
         paths = filter_files_by_size(paths, size_type)
         rel_path = path if expand else path.parent
-        paths = [str(f.relative_to(rel_path)) for f in paths if f.is_file()]
+        paths = [str(Path(p).relative_to(rel_path)) for p in paths if Path(p).is_file()]
 
     return paths
 
@@ -306,9 +304,9 @@ def _upload_file(file_path: str, url: str, ctx: tqdm) -> None:
             s = Session()
             req = Request("PUT", url, data=wrapped_object)
             prep = req.prepare()
-            prep.headers[
-                "Content-Length"
-            ] = total_file_size  # necessary to use ``CallbackIOWrapper``
+            prep.headers["Content-Length"] = str(
+                total_file_size
+            )  # necessary to use ``CallbackIOWrapper``
             response = s.send(prep)
             if response.status_code != 200:
                 secho_error_and_exit(
@@ -362,6 +360,7 @@ def write(data, *args, **kwargs):
 
             @wraps(func)
             def read(*args, **kwargs):
+                assert chunk_size is not None
                 if self._cursor >= chunk_size:
                     self._cursor = 0
                     return
diff --git a/pfcli/service/client/deployment.py b/pfcli/service/client/deployment.py
index 22aca3a..1e876b8 100644
--- a/pfcli/service/client/deployment.py
+++ b/pfcli/service/client/deployment.py
@@ -4,26 +4,28 @@
 
 from typing import List
 
-from pfcli.service.client.base import ClientService, T, safe_request
+from pfcli.service.client.base import ClientService, safe_request
 
 
-class DeploymentClientService(ClientService):
-    def get_deployment(self, deployment_id: T) -> dict:
+class DeploymentClientService(ClientService[str]):
+    def get_deployment(self, deployment_id: str) -> dict:
         response = safe_request(
             self.retrieve,
             err_prefix=f"Deployment ({deployment_id}) is not found. You may enter wrongID.",
         )(pk=deployment_id)
         return response.json()
 
-    def create_deployment(self, config=dict) -> dict:
+    def create_deployment(self, config: dict) -> dict:
         response = safe_request(self.post, err_prefix="Failed to post new deployment.")(
             json=config
         )
         return response.json()
 
-    def list_deployments(self) -> List[dict]:
+    def list_deployments(self) -> dict:
         response = safe_request(self.list, err_prefix="Failed to list deployments.")()
         return response.json()
 
-    def delete_deployment(self, deployment_id: T) -> None:
-        safe_request(self.delete, err_prefix="Failed to delete deployment.")(pk=deployment_id)
+    def delete_deployment(self, deployment_id: str) -> None:
+        safe_request(self.delete, err_prefix="Failed to delete deployment.")(
+            pk=deployment_id
+        )
diff --git a/pfcli/service/client/experiment.py b/pfcli/service/client/experiment.py
deleted file mode 100644
index 47b2054..0000000
--- a/pfcli/service/client/experiment.py
+++ /dev/null
@@ -1,28 +0,0 @@
-# Copyright (C) 2022 FriendliAI
-
-"""PeriFlow ExperimentClient Service"""
-
-
-from typing import List
-
-from pfcli.service.client.base import ClientService, T, safe_request
-
-
-class ExperimentClientService(ClientService):
-    def list_jobs_in_experiment(self, experiment_id: T) -> List[dict]:
-        response = safe_request(
-            self.list, err_prefix="Failed to fetch jobs in the experiment."
-        )(path=f"{experiment_id}/job/")
-        return response.json()["results"]
-
-    def delete_experiment(self, experiment_id: T) -> None:
-        safe_request(self.delete, err_prefix="Failed to delete experiment.")(
-            pk=experiment_id
-        )
-
-    def update_experiment_name(self, experiment_id: T, name: str) -> dict:
-        response = safe_request(
-            self.partial_update,
-            err_prefix=f"Failed to update the name of experiment to {name}",
-        )(pk=experiment_id, json={"name": name})
-        return response.json()
diff --git a/pfcli/service/client/group.py b/pfcli/service/client/group.py
index b99a2f3..4d9957a 100644
--- a/pfcli/service/client/group.py
+++ b/pfcli/service/client/group.py
@@ -6,6 +6,7 @@
 import uuid
 from string import Template
 from typing import Dict, List, Optional
+from uuid import UUID
 
 from pfcli.service import CheckpointCategory, ModelFormCategory, StorageType
 from pfcli.service.client.base import (
@@ -16,7 +17,8 @@
     UserRequestMixin,
     safe_request,
 )
-from pfcli.utils import paginated_get, validate_storage_region
+from pfcli.utils.request import paginated_get
+from pfcli.utils.validate import validate_storage_region
 
 
 class GroupClientService(ClientService):
@@ -42,7 +44,7 @@ def accept_invite(self, token: str, key: str) -> None:
             path="invite/confirm", json={"email_token": token, "key": key}
         )
 
-    def get_user(self, pf_group_id: uuid.UUID, username: str) -> dict:
+    def get_users(self, pf_group_id: uuid.UUID, username: str) -> List[dict]:
         get_response_dict = safe_request(
             self.list, err_prefix="Failed to get user in organization"
         )
@@ -75,7 +77,7 @@ def list_projects(self) -> List[dict]:
         return paginated_get(get_response_dict)
 
 
-class GroupVMConfigClientService(ClientService, GroupRequestMixin):
+class PFTGroupVMConfigClientService(ClientService, GroupRequestMixin):
     def __init__(self, template: Template, **kwargs):
         self.initialize_group()
         super().__init__(template, group_id=self.group_id, **kwargs)
@@ -86,13 +88,13 @@ def list_vm_configs(self) -> List[dict]:
         )()
         return response.json()
 
-    def get_vm_config_id_map(self) -> Dict[str, T]:
+    def get_vm_config_id_map(self) -> Dict[str, int]:
         id_map = {}
         for vm_config in self.list_vm_configs():
             id_map[vm_config["vm_config_type"]["code"]] = vm_config["id"]
         return id_map
 
-    def get_id_by_name(self, name: str) -> Optional[T]:
+    def get_id_by_name(self, name: str) -> Optional[int]:
         for vm_config in self.list_vm_configs():
             if vm_config["vm_config_type"]["code"] == name:
                 return vm_config["id"]
@@ -129,7 +131,7 @@ def create_checkpoint(
         model_form_category: ModelFormCategory,
         vendor: StorageType,
         region: str,
-        credential_id: str,
+        credential_id: UUID,
         iteration: int,
         storage_name: str,
         files: List[dict],
@@ -162,3 +164,42 @@ def create_checkpoint(
             json=request_data
         )
         return response.json()
+
+
+class GroupProjectVMQuotaClientService(ClientService[UUID], GroupRequestMixin):
+    def __init__(self, template: Template, **kwargs):
+        self.initialize_group()
+        super().__init__(template, group_id=self.group_id, **kwargs)
+
+    def list_quota(self, vm_instance_name: str, project_id: Optional[T] = None):
+        params = {"vm_config_type_code": vm_instance_name}
+        if project_id is not None:
+            params["project_id"] = str(project_id)
+        response = safe_request(
+            self.list, err_prefix="Failed to list Project VM Quotas."
+        )(params=params)
+        return response.json()
+
+    def create_project_quota(self, vm_instance_name: str, project_id: UUID, quota: int):
+        response = safe_request(
+            self.post, err_prefix="Failed to create project quota."
+        )(
+            json={
+                "group_id": str(self.group_id),
+                "vm_config_type_code": vm_instance_name,
+                "project_id": str(project_id),
+                "quota": quota,
+            }
+        )
+        return response.json()
+
+    def update_project_quota(self, quota_id: UUID, new_quota: int):
+        response = safe_request(
+            self.post, err_prefix=f"Failed to update project quota to {new_quota}."
+        )(pk=quota_id, json={"quota": new_quota})
+        return response.json()
+
+    def delete_quota(self, quota_id: UUID):
+        safe_request(self.delete, err_prefix="Failed to delete project quota.")(
+            pk=quota_id
+        )
diff --git a/pfcli/service/client/job.py b/pfcli/service/client/job.py
index 8d1c98e..a4159ce 100644
--- a/pfcli/service/client/job.py
+++ b/pfcli/service/client/job.py
@@ -3,102 +3,36 @@
 """PeriFlow JobClient Service"""
 
 import json
-import requests
+import os
 from contextlib import asynccontextmanager
-from typing import Iterator, List, Optional, Tuple
-from requests.models import Response
+from pathlib import Path
+from string import Template
+from typing import Any, Dict, Iterator, List, Optional, Tuple
+from uuid import UUID
+
+import requests
+import typer
 
 import websockets
+from requests.models import Response
+from rich.filesize import decimal
 from websockets.client import WebSocketClientProtocol
 from websockets.exceptions import ConnectionClosed
 
 from pfcli.service import JobStatus, LogType
 from pfcli.service.auth import TokenType, auto_token_refresh, get_auth_header, get_token
-from pfcli.service.client.base import ClientService, safe_request
-from pfcli.utils import paginated_get, secho_error_and_exit
-
-
-class JobClientService(ClientService):
-    def list_jobs(
-        self,
-        since: Optional[str] = None,
-        until: Optional[str] = None,
-        job_name: Optional[str] = None,
-        vm: Optional[str] = None,
-        statuses: Optional[Tuple[JobStatus]] = None,
-    ) -> List[dict]:
-        params = {
-            "created_at.since": since,
-            "created_at.until": until,
-            "job_name": job_name,
-            "vm_code": vm,
-            "status": ",".join(statuses) if statuses is not None else None,
-        }
-        return paginated_get(
-            safe_request(self.list, err_prefix="Failed to list jobs."),
-            **params,
-        )
-
-    def delete_job(self, job_id: int) -> None:
-        safe_request(self.delete, err_prefix=f"Failed to delete job ({job_id}).")(
-            pk=job_id
-        )
-
-    def get_job(self, job_id: int) -> dict:
-        response = safe_request(self.retrieve, err_prefix="Failed to list jobs.")(
-            pk=job_id
-        )
-        return response.json()
-
-    def cancel_job(self, job_id: int) -> None:
-        safe_request(self.post, err_prefix=f"Failed to cancel job ({job_id}).")(
-            path=f"{job_id}/cancel/"
-        )
-
-    def terminate_job(self, job_id: int) -> None:
-        safe_request(self.post, err_prefix=f"Failed to terminate job ({job_id}).")(
-            path=f"{job_id}/terminate/"
-        )
-
-    def get_text_logs(
-        self,
-        job_id: int,
-        num_records: int,
-        head: bool = False,
-        log_types: Optional[List[str]] = None,
-        machines: Optional[List[int]] = None,
-        content: Optional[str] = None,
-    ) -> List[dict]:
-        request_data = {"limit": num_records}
-        if head:
-            request_data["ascending"] = "true"
-        else:
-            request_data["ascending"] = "false"
-        if content is not None:
-            request_data["content"] = content
-        if log_types is not None:
-            request_data["log_types"] = ",".join(log_types)
-        if machines is not None:
-            request_data["node_ranks"] = ",".join(
-                [str(machine) for machine in machines]
-            )
-
-        logs = paginated_get(
-            safe_request(self.list, err_prefix="Failed to fetch text logs."),
-            path=f"{job_id}/text_log/",
-            **request_data,
-        )
-        if not head:
-            logs.reverse()
-
-        return logs
+from pfcli.service.client.base import ClientService, ProjectRequestMixin, safe_request
+from pfcli.service.formatter import TreeFormatter
+from pfcli.utils.format import secho_error_and_exit
+from pfcli.utils.fs import get_workspace_files, zip_dir
+from pfcli.utils.request import paginated_get
 
 
 class JobWebSocketClientService(ClientService):
     @asynccontextmanager
-    async def _connect(self, job_id: int) -> Iterator[WebSocketClientProtocol]:
+    async def _connect(self, job_id: UUID) -> Iterator[WebSocketClientProtocol]:
         access_token = get_token(TokenType.ACCESS)
-        base_url = self.url_template.render(job_id)
+        base_url = self.url_template.render(**self.url_kwargs, pk=job_id)
         url = f"{base_url}?token={access_token}"
         async with websockets.connect(url) as websocket:
             yield websocket
@@ -128,7 +62,10 @@ async def _subscribe(
 
     @asynccontextmanager
     async def open_connection(
-        self, job_id: int, log_types: Optional[List[str]], machines: Optional[List[int]]
+        self,
+        job_id: UUID,
+        log_types: Optional[List[str]],
+        machines: Optional[List[int]],
     ):
         if log_types is None:
             sources = [f"process.{x.value}" for x in LogType]
@@ -161,13 +98,21 @@ async def __anext__(self):
             secho_error_and_exit("Error occurred while decoding websocket response...")
 
 
-class JobCheckpointClientService(ClientService):
+class ProjectJobCheckpointClientService(ClientService, ProjectRequestMixin):
+    def __init__(self, template: Template, **kwargs):
+        self.initialize_project()
+        super().__init__(template, project_id=self.project_id, **kwargs)
+
     def list_checkpoints(self) -> List[dict]:
         response = safe_request(self.list, err_prefix="Failed to list checkpoints.")()
         return response.json()
 
 
-class JobArtifactClientService(ClientService):
+class ProjectJobArtifactClientService(ClientService, ProjectRequestMixin):
+    def __init__(self, template: Template, **kwargs):
+        self.initialize_project()
+        super().__init__(template, project_id=self.project_id, **kwargs)
+
     def list_artifacts(self) -> List[dict]:
         response = safe_request(self.list, err_prefix="Failed to list artifacts.")()
         return response.json()
@@ -187,7 +132,7 @@ def get_artifact_download_url(self, artifact_id: int) -> dict:
         return response.json()
 
 
-class JobTemplateClientService(ClientService):
+class JobTemplateClientService(ClientService[UUID]):
     def list_job_template_names(self) -> List[str]:
         response = safe_request(
             self.list, err_prefix="Failed to list job template names."
@@ -202,3 +147,132 @@ def get_job_template_by_name(self, name: str) -> Optional[dict]:
             if template["name"] == name:
                 return template
         return None
+
+    def get_job_template(self, job_template_id: UUID) -> dict:
+        response = safe_request(
+            self.retrieve,
+            err_prefix=f"Job template (ID: {job_template_id}) is not found.",
+        )(pk=job_template_id)
+        return response.json()
+
+
+class ProjectJobClientService(ClientService, ProjectRequestMixin):
+    def __init__(self, template: Template, **kwargs):
+        self.initialize_project()
+        super().__init__(template, project_id=self.project_id, **kwargs)
+
+    def list_jobs(
+        self,
+        since: Optional[str] = None,
+        until: Optional[str] = None,
+        job_name: Optional[str] = None,
+        vm: Optional[str] = None,
+        statuses: Optional[Tuple[JobStatus]] = None,
+        user_ids: Optional[List[UUID]] = None,
+    ) -> List[dict]:
+        params = {
+            "created_at.since": since,
+            "created_at.until": until,
+            "job_name": job_name,
+            "vm_code": vm,
+            "status": ",".join(statuses) if statuses is not None else None,
+            "user_ids": ",".join(str(user_id) for user_id in user_ids)
+            if user_ids is not None
+            else None,
+        }
+        return paginated_get(
+            safe_request(self.list, err_prefix="Failed to list jobs in project."),
+            **params,
+        )
+
+    def run_job(self, config: Dict, workspace_dir: Optional[Path]) -> dict:
+        job_request = safe_request(self.post, err_prefix="Failed to run job.")
+        if workspace_dir is not None:
+            typer.secho("Preparing workspace directory...", fg=typer.colors.MAGENTA)
+            workspace_dir = workspace_dir.resolve()
+            workspace_files = get_workspace_files(workspace_dir)
+            workspace_size = sum(f.stat().st_size for f in workspace_files)
+            if workspace_size <= 0 or workspace_size > 100 * 1024 * 1024:
+                secho_error_and_exit(
+                    f"Workspace directory size ({decimal(workspace_size)}) should be 0 < size <= 100MB."
+                )
+            tree_formatter = TreeFormatter(
+                name="Job Workspace",
+                root=os.path.join(
+                    config["job_setting"]["workspace"]["mount_path"], workspace_dir.name
+                ),
+            )
+            typer.secho(
+                "Workspace is prepared and will be mounted as the following structure.",
+                fg=typer.colors.MAGENTA,
+            )
+            tree_formatter.render(
+                [
+                    {"path": f.relative_to(workspace_dir), "size": f.stat().st_size}
+                    for f in workspace_files
+                ]
+            )
+            workspace_zip = Path(workspace_dir.parent / (workspace_dir.name + ".zip"))
+            with zip_dir(workspace_dir, workspace_files, workspace_zip) as zip_file:
+                files = {"workspace_zip": ("workspace.zip", zip_file)}
+                response = job_request(data={"data": json.dumps(config)}, files=files)
+        else:
+            response = job_request(json=config)
+        return response.json()
+
+    def delete_job(self, job_number: int) -> None:
+        safe_request(self.delete, err_prefix=f"Failed to delete job ({job_number}).")(
+            pk=job_number
+        )
+
+    def get_job(self, job_number: int) -> dict:
+        response = safe_request(
+            self.retrieve, err_prefix=f"Failed to get job ({job_number})."
+        )(pk=job_number)
+        return response.json()
+
+    def cancel_job(self, job_number: int) -> None:
+        safe_request(self.post, err_prefix=f"Failed to cancel job ({job_number}).")(
+            path=f"{job_number}/cancel/"
+        )
+
+    def terminate_job(self, job_number: int) -> None:
+        safe_request(self.post, err_prefix=f"Failed to terminate job ({job_number}).")(
+            path=f"{job_number}/terminate/"
+        )
+
+    def get_text_logs(
+        self,
+        job_number: int,
+        num_records: int,
+        head: bool = False,
+        log_types: Optional[List[str]] = None,
+        machines: Optional[List[int]] = None,
+        content: Optional[str] = None,
+    ) -> List[dict]:
+        request_data: Dict[str, Any] = {"limit": num_records}
+        if head:
+            request_data["ascending"] = "true"
+        else:
+            request_data["ascending"] = "false"
+        if content is not None:
+            request_data["content"] = content
+        if log_types is not None:
+            request_data["log_types"] = ",".join(log_types)
+        if machines is not None:
+            request_data["node_ranks"] = ",".join(
+                [str(machine) for machine in machines]
+            )
+
+        logs = paginated_get(
+            safe_request(
+                self.list,
+                err_prefix=f"Failed to fetch text logs of job ({job_number}).",
+            ),
+            path=f"{job_number}/text_log/",
+            **request_data,
+        )
+        if not head:
+            logs.reverse()
+
+        return logs
diff --git a/pfcli/service/client/metrics.py b/pfcli/service/client/metrics.py
index 7ed8dd9..7d0804f 100644
--- a/pfcli/service/client/metrics.py
+++ b/pfcli/service/client/metrics.py
@@ -7,7 +7,7 @@
 from typing import List, TypeVar
 
 from pfcli.service.client.base import ClientService, safe_request
-from pfcli.utils import datetime_to_simple_string, utc_to_local
+from pfcli.utils.format import datetime_to_simple_string, utc_to_local
 
 MetricsType = TypeVar("MetricsType")
 
diff --git a/pfcli/service/client/project.py b/pfcli/service/client/project.py
index 9226589..804a0ef 100644
--- a/pfcli/service/client/project.py
+++ b/pfcli/service/client/project.py
@@ -3,21 +3,14 @@
 """PeriFlow ProjectClient Service"""
 
 
-import json
-import os
-import uuid
-from pathlib import Path
-from string import Template
-from typing import List, Optional, Tuple
 from requests import HTTPError
-
-import typer
-from rich.filesize import decimal
+from string import Template
+from typing import List, Optional
+from uuid import UUID
 
 from pfcli.service import (
     CloudType,
     CredType,
-    JobStatus,
     LockStatus,
     StorageType,
     cred_type_map,
@@ -29,24 +22,26 @@
     T,
     safe_request,
 )
-from pfcli.service.formatter import TreeFormatter
-from pfcli.utils import (
-    get_workspace_files,
-    paginated_get,
-    secho_error_and_exit,
-    validate_storage_region,
-    zip_dir,
-)
+from pfcli.utils.format import secho_error_and_exit
+from pfcli.utils.request import paginated_get
+from pfcli.utils.validate import validate_storage_region
+
 
+def find_project_id(projects: List[dict], project_name: str) -> UUID:
+    for project in projects:
+        if project["name"] == project_name:
+            return UUID(project["id"])
+    secho_error_and_exit(f"No project exists with name {project_name}.")
 
-class ProjectClientService(ClientService):
-    def get_project(self, pf_project_id: uuid.UUID) -> dict:
+
+class ProjectClientService(ClientService[UUID]):
+    def get_project(self, pf_project_id: UUID) -> dict:
         response = safe_request(self.retrieve, err_prefix="Failed to get a project.")(
             pk=pf_project_id
         )
         return response.json()
 
-    def check_project_membership(self, pf_project_id: uuid.UUID) -> bool:
+    def check_project_membership(self, pf_project_id: UUID) -> bool:
         try:
             self.retrieve(pf_project_id)
         except HTTPError:
@@ -54,105 +49,19 @@ def check_project_membership(self, pf_project_id: uuid.UUID) -> bool:
         else:
             return True
 
-    def delete_project(self, pf_project_id: uuid.UUID) -> None:
+    def delete_project(self, pf_project_id: UUID) -> None:
         safe_request(self.delete, err_prefix="Failed to delete a project.")(
             pk=pf_project_id
         )
 
-    def list_users(self, pf_project_id: str) -> List[dict]:
+    def list_users(self, pf_project_id: UUID) -> List[dict]:
         get_response_dict = safe_request(
             self.list, err_prefix="Failed to list users in the current project"
         )
         return paginated_get(get_response_dict, path=f"{pf_project_id}/pf_user")
 
 
-class ProjectExperimentClientService(ClientService, ProjectRequestMixin):
-    def __init__(self, template: Template, **kwargs):
-        self.initialize_project()
-        super().__init__(template, project_id=self.project_id, **kwargs)
-
-    def list_experiments(self) -> List[dict]:
-        response = safe_request(self.list, err_prefix="Failed to list experiments.")()
-        return response.json()
-
-    def get_id_by_name(self, name: str) -> Optional[T]:
-        response = safe_request(
-            self.list, err_prefix="Failed to get experiment info."
-        )()
-        for experiment in response.json():
-            if experiment["name"] == name:
-                return experiment["id"]
-        return None
-
-    def create_experiment(self, name: str) -> dict:
-        response = safe_request(self.post, err_prefix="Failed to post new experiment.")(
-            data={"name": name}
-        )
-        return response.json()
-
-
-class ProjectJobClientService(ClientService, ProjectRequestMixin):
-    def __init__(self, template: Template, **kwargs):
-        self.initialize_project()
-        super().__init__(template, project_id=self.project_id, **kwargs)
-
-    def list_jobs(
-        self,
-        since: Optional[str] = None,
-        until: Optional[str] = None,
-        job_name: Optional[str] = None,
-        vm: Optional[str] = None,
-        statuses: Optional[Tuple[JobStatus]] = None,
-    ) -> List[dict]:
-        params = {
-            "created_at.since": since,
-            "created_at.until": until,
-            "job_name": job_name,
-            "vm_code": vm,
-            "status": ",".join(statuses) if statuses is not None else None,
-        }
-        return paginated_get(
-            safe_request(self.list, err_prefix="Failed to list jobs in project."),
-            **params,
-        )
-
-    def run_job(self, config: dict, workspace_dir: Optional[Path]) -> dict:
-        job_request = safe_request(self.post, err_prefix="Failed to run job.")
-        if workspace_dir is not None:
-            typer.secho("Preparing workspace directory...", fg=typer.colors.MAGENTA)
-            workspace_dir = workspace_dir.resolve()
-            workspace_files = get_workspace_files(workspace_dir)
-            workspace_size = sum(f.stat().st_size for f in workspace_files)
-            if workspace_size <= 0 or workspace_size > 100 * 1024 * 1024:
-                secho_error_and_exit(
-                    f"Workspace directory size ({decimal(workspace_size)}) should be 0 < size <= 100MB."
-                )
-            tree_formatter = TreeFormatter(
-                name="Job Workspace",
-                root=os.path.join(
-                    config["job_setting"]["workspace"]["mount_path"], workspace_dir.name
-                ),
-            )
-            typer.secho(
-                "Workspace is prepared and will be mounted as the following structure.",
-                fg=typer.colors.MAGENTA,
-            )
-            tree_formatter.render(
-                [
-                    {"path": f.relative_to(workspace_dir), "size": f.stat().st_size}
-                    for f in workspace_files
-                ]
-            )
-            workspace_zip = Path(workspace_dir.parent / (workspace_dir.name + ".zip"))
-            with zip_dir(workspace_dir, workspace_files, workspace_zip) as zip_file:
-                files = {"workspace_zip": ("workspace.zip", zip_file)}
-                response = job_request(data={"data": json.dumps(config)}, files=files)
-        else:
-            response = job_request(json=config)
-        return response.json()
-
-
-class ProjectDataClientService(ClientService, ProjectRequestMixin):
+class ProjectDataClientService(ClientService[int], ProjectRequestMixin):
     def __init__(self, template: Template, **kwargs):
         self.initialize_project()
         super().__init__(template, project_id=self.project_id, **kwargs)
@@ -161,7 +70,7 @@ def list_datasets(self) -> List[dict]:
         response = safe_request(self.list, err_prefix="Failed to list dataset info.")()
         return response.json()
 
-    def get_id_by_name(self, name: str) -> Optional[T]:
+    def get_id_by_name(self, name: str) -> Optional[int]:
         datasets = self.list_datasets()
         for dataset in datasets:
             if dataset["name"] == name:
@@ -174,7 +83,7 @@ def create_dataset(
         vendor: StorageType,
         region: str,
         storage_name: str,
-        credential_id: Optional[T],
+        credential_id: Optional[UUID],
         metadata: dict,
         files: List[dict],
         active: bool,
@@ -187,7 +96,7 @@ def create_dataset(
             "vendor": vendor_name,
             "region": region,
             "storage_name": storage_name,
-            "credential_id": credential_id,
+            "credential_id": str(credential_id),
             "metadata": metadata,
             "files": files,
             "active": active,
@@ -198,7 +107,7 @@ def create_dataset(
         return response.json()
 
 
-class ProjectVMQuotaClientService(ClientService, ProjectRequestMixin):
+class PFTProjectVMQuotaClientService(ClientService, ProjectRequestMixin):
     def __init__(self, template: Template, **kwargs):
         self.initialize_project()
         super().__init__(template, project_id=self.project_id, **kwargs)
@@ -207,7 +116,7 @@ def list_vm_quotas(
         self,
         vendor: Optional[CloudType] = None,
         device_type: Optional[str] = None,
-    ) -> Optional[List[dict]]:
+    ) -> List[dict]:
         response = safe_request(self.list, err_prefix="Failed to list VM quota info.")()
         vm_dict_list = response.json()
         if vendor is not None:
@@ -255,13 +164,13 @@ def create_credential(
         return response.json()
 
 
-class ProjectVMConfigClientService(ClientService, ProjectRequestMixin):
+class PFTProjectVMConfigClientService(ClientService[int], ProjectRequestMixin):
     def __init__(self, template: Template, **kwargs):
         self.initialize_project()
         super().__init__(template, project_id=self.project_id, **kwargs)
 
     def list_vm_locks(
-        self, vm_config_id: T, lock_status_list: List[LockStatus]
+        self, vm_config_id: int, lock_status_list: List[LockStatus]
     ) -> List[dict]:
         status_param = ",".join(lock_status_list)
         response = safe_request(self.list, err_prefix="Failed to inspect locked VMs.")(
@@ -269,7 +178,7 @@ def list_vm_locks(
         )
         return response.json()
 
-    def get_vm_count_in_use(self, vm_config_id: T) -> int:
+    def get_vm_count_in_use(self, vm_config_id: int) -> int:
         vm_locks = self.list_vm_locks(
             vm_config_id, [LockStatus.ACTIVE, LockStatus.DELETING]
         )
diff --git a/pfcli/service/client/user.py b/pfcli/service/client/user.py
index 5b12efe..7f1b6e2 100644
--- a/pfcli/service/client/user.py
+++ b/pfcli/service/client/user.py
@@ -4,6 +4,7 @@
 
 from string import Template
 from typing import List
+from uuid import UUID
 
 from pfcli.service import GroupRole, ProjectRole
 from pfcli.service.client.base import (
@@ -12,7 +13,7 @@
     UserRequestMixin,
     safe_request,
 )
-from pfcli.utils import paginated_get
+from pfcli.utils.request import paginated_get
 
 
 class UserMFAService(ClientService):
@@ -52,7 +53,7 @@ def change_password(self, old_password: str, new_password: str) -> None:
         )
 
     def set_group_privilege(
-        self, pf_group_id: str, pf_user_id: str, privilege_level: GroupRole
+        self, pf_group_id: UUID, pf_user_id: UUID, privilege_level: GroupRole
     ) -> None:
         safe_request(
             self.partial_update, err_prefix="Failed to update privilege level in group"
@@ -62,7 +63,7 @@ def set_group_privilege(
             json={"privilege_level": privilege_level.value},
         )
 
-    def get_project_membership(self, pf_project_id: str) -> dict:
+    def get_project_membership(self, pf_project_id: UUID) -> dict:
         response = safe_request(
             self.retrieve, err_prefix="Failed identify member in project"
         )(
@@ -72,7 +73,7 @@ def get_project_membership(self, pf_project_id: str) -> dict:
         return response.json()
 
     def add_to_project(
-        self, pf_user_id: str, pf_project_id: str, access_level: ProjectRole
+        self, pf_user_id: UUID, pf_project_id: UUID, access_level: ProjectRole
     ) -> None:
         safe_request(self.post, err_prefix="Failed to add user to project")(
             path=f"{pf_user_id}/pf_project/{pf_project_id}",
@@ -80,7 +81,7 @@ def add_to_project(
         )
 
     def set_project_privilege(
-        self, pf_user_id: str, pf_project_id: str, access_level: ProjectRole
+        self, pf_user_id: UUID, pf_project_id: UUID, access_level: ProjectRole
     ) -> None:
         safe_request(
             self.partial_update,
@@ -97,9 +98,9 @@ def __init__(self, template: Template, **kwargs):
         self.initialize_user()
         super().__init__(template, pf_user_id=self.user_id, **kwargs)
 
-    def get_group_info(self) -> list:
+    def get_group_info(self) -> dict:
         response = safe_request(self.list, err_prefix="Failed to get my group info.")()
-        return response.json()
+        return response.json()[0]
 
 
 class UserGroupProjectClientService(ClientService, UserRequestMixin, GroupRequestMixin):
diff --git a/pfcli/service/cloud.py b/pfcli/service/cloud.py
index 66bb674..39d156d 100644
--- a/pfcli/service/cloud.py
+++ b/pfcli/service/cloud.py
@@ -20,7 +20,7 @@
 from azure.storage.blob import BlobServiceClient
 
 from pfcli.service import StorageType
-from pfcli.utils import secho_error_and_exit
+from pfcli.utils.format import secho_error_and_exit
 
 
 C = TypeVar("C", bound="CloudStorageHelper")
diff --git a/pfcli/service/config.py b/pfcli/service/config.py
index 48106dd..87c6a12 100644
--- a/pfcli/service/config.py
+++ b/pfcli/service/config.py
@@ -13,6 +13,7 @@
     Optional,
     Union,
 )
+from uuid import UUID
 
 import yaml
 import typer
@@ -36,13 +37,11 @@
     build_client,
 )
 from pfcli.service.cloud import build_storage_helper
-from pfcli.utils import get_default_editor, open_editor, secho_error_and_exit
+from pfcli.utils.prompt import get_default_editor, open_editor
+from pfcli.utils.format import secho_error_and_exit
 
 
 DEFAULT_TEMPLATE_CONFIG = """\
-# The name of experiment
-experiment:
-
 # The name of job
 name:
 
@@ -291,7 +290,7 @@ def render(self) -> str:
 class PredefinedJobConfigService(JobConfigService):
     """Predefined job template configuration service"""
 
-    model_name: Optional[str] = None
+    template_id: Optional[UUID] = None
     model_config: Optional[dict] = None
 
     def start_interaction(self) -> None:
@@ -299,13 +298,12 @@ def start_interaction(self) -> None:
             ServiceType.JOB_TEMPLATE
         )
 
-        template_names = job_template_client_service.list_job_template_names()
-        self.model_name = typer.prompt(
-            "Which job do you want to run? Choose one in the following catalog:\n",
-            type=Choice(template_names),
+        self.template_id = typer.prompt(
+            "Enter the predefined job template ID",
             prompt_suffix="\n>> ",
+            type=UUID,
         )
-        template = job_template_client_service.get_job_template_by_name(self.model_name)
+        template = job_template_client_service.get_job_template(self.template_id)
         assert template is not None
         self.model_config = template["data_example"]
 
@@ -328,10 +326,11 @@ def render(self) -> str:
 
         yaml_str = DEFAULT_TEMPLATE_CONFIG
         yaml_str += PREDEFINED_JOB_SETTING_CONFIG
-        yaml_str += f"  template_name: {self.model_name}\n"
-        yaml_str += f"  model_config:\n"
-        for k, v in self.model_config.items():
-            yaml_str += f"    {k}: {v}\n"
+        yaml_str += f"  template_id: {self.template_id}\n"
+        if self.model_config:
+            yaml_str += f"  model_config:\n"
+            for k, v in self.model_config.items():
+                yaml_str += f"    {k}: {v}\n"
 
         if self.use_data:
             yaml_str += DATA_CONFIG
@@ -382,7 +381,7 @@ def start_interaction(self) -> None:
         self._validate_schema(schema)
         self.ready = True
 
-    def start_interaction_for_update(self, credential_id: str) -> None:
+    def start_interaction_for_update(self, credential_id: UUID) -> None:
         cred_client: CredentialClientService = build_client(ServiceType.CREDENTIAL)
         prev_cred = cred_client.get_credential(credential_id)
 
@@ -437,7 +436,7 @@ class DataConfigService(InteractiveConfigMixin):
     vendor: Optional[StorageType] = None
     region: Optional[str] = None
     storage_name: Optional[str] = None
-    credential_id: Optional[str] = None
+    credential_id: Optional[UUID] = None
     metadata: Optional[dict] = field(default_factory=dict)
     files: Optional[List[dict]] = field(default_factory=list)
 
@@ -453,6 +452,7 @@ def _list_available_credentials(self, vendor_type: StorageType) -> List[dict]:
 
     def _get_credential(self) -> dict:
         client: CredentialClientService = build_client(ServiceType.CREDENTIAL)
+        assert self.credential_id is not None
         return client.get_credential(self.credential_id)["value"]
 
     def start_interaction_common(self) -> None:
@@ -485,6 +485,7 @@ def start_interaction_common(self) -> None:
             show_choices=False,
             prompt_suffix="\n>> ",
         )
+        self.credential_id = UUID(self.credential_id)
         credential_value = self._get_credential()
         storage_helper = build_storage_helper(self.vendor, credential_value)
         self.files = storage_helper.list_storage_files(self.storage_name)
@@ -583,6 +584,7 @@ def start_interaction(self) -> None:
 @dataclass
 class DeploymentConfigService(InteractiveConfigMixin):
     """Deployment template configuration service."""
+
     ready: bool = False
     use_specific_image: bool = False
     use_ckpt_config: bool = False
@@ -617,7 +619,7 @@ def start_interaction(self):
             prompt_suffix="\n>> ",
         )
         self.ready = True
-    
+
     def render(self) -> str:
         assert self.ready
 
diff --git a/pfcli/service/formatter.py b/pfcli/service/formatter.py
index df4faba..3189849 100644
--- a/pfcli/service/formatter.py
+++ b/pfcli/service/formatter.py
@@ -90,7 +90,7 @@ def get_renderable(
     def apply_styling(self, header: str, **kwargs) -> None:
         self._styling_map[header] = kwargs
 
-    def add_substitution_rule(self, before: str, after: str) -> None:
+    def add_substitution_rule(self, before: str, after: Any) -> None:
         self._substitution_rule[before] = after
 
     def _substitute(self, val: str) -> str:
diff --git a/pfcli/settings.py b/pfcli/settings.py
deleted file mode 100644
index abd2c3f..0000000
--- a/pfcli/settings.py
+++ /dev/null
@@ -1,22 +0,0 @@
-# Copyright (C) 2022 FriendliAI
-
-"""Periflow CLI Settings"""
-
-from pathlib import Path
-
-class Settings():
-    pfs_only: bool = False
-
-    # uris
-    periflow_directory = Path.home() / ".periflow"
-    periflow_api_server = "https://api-staging.friendli.ai/api/"
-    periflow_ws_server = "wss://api-ws-staging.friendli.ai/ws/"
-    periflow_discuss_url = "https://discuss-staging.friendli.ai/"
-    periflow_mr_server = "https://pfmodelregistry-staging.friendli.ai/"
-    periflow_serving_server = "http://0.0.0.0:8000/"
-    periflow_auth_server = "https://pfauth-staging.friendli.ai/"
-    periflow_meter_server = "https://pfmeter-staging.friendli.ai/"
-    periflow_observatory_server = "https://pfo-staging.friendli.ai/"
-
-
-settings = Settings()
diff --git a/pfcli/utils.py b/pfcli/utils.py
deleted file mode 100644
index dc8669d..0000000
--- a/pfcli/utils.py
+++ /dev/null
@@ -1,337 +0,0 @@
-# Copyright (C) 2021 FriendliAI
-
-"""PeriFlow CLI Shared Utilities"""
-
-import os
-import zipfile
-from concurrent.futures import ThreadPoolExecutor, wait, FIRST_EXCEPTION
-from contextlib import contextmanager
-from datetime import datetime, timedelta, timezone
-from dateutil.tz import tzlocal
-from pathlib import Path
-from subprocess import CalledProcessError, check_call
-from typing import Callable, Optional, List
-from urllib.parse import urljoin
-
-import pathspec
-import typer
-import requests
-from requests.exceptions import HTTPError
-from requests.models import Response
-from tqdm import tqdm
-from tqdm.utils import CallbackIOWrapper
-
-from pfcli.service import CloudType, StorageType, storage_region_map, cloud_region_map
-from pfcli.settings import settings
-
-
-def get_periflow_directory() -> Path:
-    settings.periflow_directory.mkdir(exist_ok=True)
-    return settings.periflow_directory
-
-def datetime_to_pretty_str(past: Optional[datetime], long_list: bool = False):
-    cur = datetime.now().astimezone()
-    delta = cur - past
-    if long_list:
-        if delta < timedelta(minutes=1):
-            return f"{delta.seconds % 60}s ago"
-        if delta < timedelta(hours=1):
-            return f"{round((delta.seconds % 3600) / 60)}m {delta.seconds % 60}s ago"
-        elif delta < timedelta(days=1):
-            return f"{delta.seconds // 3600}h {round((delta.seconds % 3600) / 60)}m {delta.seconds % 60}s ago"
-        elif delta < timedelta(days=3):
-            return (
-                f"{delta.days}d {delta.seconds // 3600}h "
-                f"{round((delta.seconds % 3600) / 60)}m ago"
-            )
-        else:
-            return past.astimezone(tz=cur.tzinfo).strftime("%Y-%m-%d %H:%M:%S")
-    else:
-        if delta < timedelta(hours=1):
-            return f"{round((delta.seconds % 3600) / 60)} mins ago"
-        elif delta < timedelta(days=1):
-            return f"{round(delta.seconds / 3600)} hours ago"
-        else:
-            return f"{delta.days + round(delta.seconds / (3600 * 24))} days ago"
-
-
-def timedelta_to_pretty_str(start: datetime, finish: datetime, long_list: bool = False):
-    delta = finish - start
-    if long_list:
-        if delta < timedelta(minutes=1):
-            return f"{(delta.seconds % 60)}s"
-        if delta < timedelta(hours=1):
-            return f"{(delta.seconds % 3600) // 60}m {(delta.seconds % 60)}s"
-        elif delta < timedelta(days=1):
-            return f"{delta.seconds // 3600}h {(delta.seconds % 3600) // 60}m {(delta.seconds % 60)}s"
-        else:
-            return (
-                f"{delta.days}d {delta.seconds // 3600}h "
-                f"{(delta.seconds % 3600) // 60}m {delta.seconds % 60}s"
-            )
-    else:
-        if delta < timedelta(hours=1):
-            return f"{round((delta.seconds % 3600) / 60)} mins"
-        elif delta < timedelta(days=1):
-            return f"{round(delta.seconds / 3600)} hours"
-        else:
-            return f"{delta.days + round(delta.seconds / (3600 * 24))} days"
-
-
-def get_auth_uri(path: str) -> str:
-    return urljoin(settings.periflow_auth_server, path)
-
-
-def get_uri(path: str) -> str:
-    return urljoin(settings.periflow_api_server, path)
-
-
-def get_wss_uri(path: str) -> str:
-    return urljoin(settings.periflow_ws_server, path)
-
-
-def get_pfs_uri(path: str) -> str:
-    return urljoin(settings.periflow_serve_server, path)
-
-
-def get_mr_uri(path: str) -> str:
-    return urljoin(settings.periflow_mr_server, path)
-
-
-def get_meter_uri(path: str) -> str:
-    return urljoin(settings.periflow_meter_server, path)
-
-
-def get_observatory_uri(path: str) -> str:
-    return urljoin(settings.periflow_observatory_server, path)
-
-
-def secho_error_and_exit(text: str, color: str = typer.colors.RED):
-    typer.secho(text, err=True, fg=color)
-    raise typer.Exit(1)
-
-
-def get_remaining_terminal_columns(occupied: int) -> int:
-    return os.get_terminal_size().columns - occupied
-
-
-def utc_to_local(dt: datetime) -> datetime:
-    return dt.replace(tzinfo=timezone.utc).astimezone(tz=None)
-
-
-def datetime_to_simple_string(dt: datetime) -> str:
-    return dt.strftime("%Y-%m-%d %H:%M:%S")
-
-
-@contextmanager
-def zip_dir(base_path: Path, target_files: List[Path], zip_path: Path):
-    with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_STORED) as zip_file:
-        for file in target_files:
-            zip_file.write(file, file.relative_to(base_path.parent))
-    typer.secho("Uploading workspace directory...", fg=typer.colors.MAGENTA)
-    try:
-        yield zip_path.open("rb")
-    finally:
-        zip_path.unlink()
-
-
-def open_editor(path: str, editor: Optional[str] = None):
-    default_editor = editor or get_default_editor()
-    try:
-        check_call([default_editor, path])
-    except CalledProcessError:
-        typer.secho("", fg=typer.colors.RED)
-
-
-def get_default_editor() -> str:
-    return os.environ.get("PERIFLOW_CLI_EDITOR", "vim")
-
-
-def validate_storage_region(vendor: StorageType, region: str):
-    available_regions = storage_region_map[vendor]
-    if region not in available_regions:
-        secho_error_and_exit(
-            f"'{region}' is not supported region for {vendor}. Please choose another one in {available_regions}."
-        )
-
-
-def validate_cloud_region(vendor: CloudType, region: str):
-    available_regions = cloud_region_map[vendor]
-    if region not in available_regions:
-        secho_error_and_exit(
-            f"'{region}' is not supported region for {vendor}. Please choose another one in {available_regions}."
-        )
-
-
-def get_workspace_files(dir_path: Path) -> List[Path]:
-    ignore_file = dir_path / ".pfignore"
-    all_files = set(x for x in dir_path.rglob("*") if x.is_file() and x != ignore_file)
-
-    if not ignore_file.exists():
-        return list(all_files)
-
-    with open(ignore_file, "r", encoding="utf-8") as f:
-        ignore_patterns = f.read()
-    spec = pathspec.PathSpec.from_lines(
-        pathspec.patterns.GitWildMatchPattern, ignore_patterns.splitlines()
-    )
-    matched_files = set(dir_path / x for x in spec.match_tree_files(dir_path))
-    return list(all_files.difference(matched_files))
-
-
-def storage_path_to_local_path(
-    storage_path: str, source_path: Path, expand: bool
-) -> str:
-    if source_path.is_file():
-        return str(source_path)
-
-    if expand:
-        return str(source_path / Path(storage_path))
-
-    return str(source_path / Path(storage_path.split("/", 1)[1]))
-
-
-def get_file_info(storage_path: str, source_path: Path, expand: bool) -> dict:
-    loacl_path = storage_path_to_local_path(storage_path, source_path, expand)
-    return {
-        "name": os.path.basename(storage_path),
-        "path": storage_path,
-        "mtime": datetime.fromtimestamp(
-            os.stat(loacl_path).st_mtime, tz=tzlocal()
-        ).isoformat(),
-        "size": os.stat(loacl_path).st_size,
-    }
-
-
-def get_content_size(url: str) -> int:
-    response = requests.get(url, stream=True)
-    if response.status_code != 200:
-        secho_error_and_exit("Failed to download (invalid url)")
-    return int(response.headers["Content-Length"])
-
-
-def download_range(url: str, start: int, end: int, output: str, ctx: tqdm) -> None:
-    headers = {"Range": f"bytes={start}-{end}"}
-    response = requests.get(url, headers=headers, stream=True)
-
-    with open(output, "wb") as f:
-        wrapped_object = CallbackIOWrapper(ctx.update, f, "write")
-        for part in response.iter_content(1024):
-            wrapped_object.write(part)
-
-
-def download_file_simple(url: str, out: str, content_length: int) -> None:
-    response = requests.get(url, stream=True)
-    with tqdm.wrapattr(
-        open(out, "wb"), "write", miniters=1, total=content_length
-    ) as fout:
-        for chunk in response.iter_content(chunk_size=4096):
-            fout.write(chunk)
-
-
-def download_file_parallel(
-    url: str, out: str, content_length: int, chunk_size: int = 1024 * 1024 * 4
-) -> None:
-    chunks = range(0, content_length, chunk_size)
-
-    temp_out_prefix = os.path.join(os.path.dirname(out), f".{os.path.basename(out)}")
-
-    try:
-        with tqdm(
-            total=content_length, unit="B", unit_scale=True, unit_divisor=1024
-        ) as t:
-            with ThreadPoolExecutor() as executor:
-                futs = [
-                    executor.submit(
-                        download_range,
-                        url,
-                        start,
-                        start + chunk_size - 1,
-                        f"{temp_out_prefix}.part{i}",
-                        t,
-                    )
-                    for i, start in enumerate(chunks)
-                ]
-                wait(futs, return_when=FIRST_EXCEPTION)
-
-        # Merge partitioned files
-        with open(out, "wb") as f:
-            for i in range(len(chunks)):
-                chunk_path = f"{temp_out_prefix}.part{i}"
-                with open(chunk_path, "rb") as chunk_f:
-                    f.write(chunk_f.read())
-
-                os.remove(chunk_path)
-    finally:
-        # Clean up zombie temporary partitioned files
-        for i in range(len(chunks)):
-            chunk_path = f"{temp_out_prefix}.part{i}"
-            if os.path.isfile(chunk_path):
-                os.remove(chunk_path)
-
-
-def download_file(url: str, out: str) -> None:
-    file_size = get_content_size(url)
-
-    # Create directory if not exists
-    dirpath = os.path.dirname(out)
-    os.makedirs(dirpath, exist_ok=True)
-
-    if file_size >= 16 * 1024 * 1024:
-        download_file_parallel(url, out, file_size)
-    else:
-        download_file_simple(url, out, file_size)
-
-
-def decode_http_err(exc: HTTPError) -> str:
-    try:
-        if exc.response.status_code == 500:
-            error_str = f"Internal Server Error: Please contact to system admin via {settings.periflow_discuss_url}"
-        elif exc.response.status_code == 404:
-            error_str = (
-                "Not Found: The requested resource is not found. Please check it again. "
-                f"If you cannot find out why this error occurs, please visit {settings.periflow_discuss_url}."
-            )
-        else:
-            response = exc.response
-            detail_json = response.json()
-            if "detail" in detail_json:
-                error_str = f"Error Code: {response.status_code}\nDetail: {detail_json['detail']}"
-            elif "error_description" in detail_json:
-                error_str = f"Error Code: {response.status_code}\nDetail: {detail_json['error_description']}"
-            else:
-                error_str = f"Error Code: {response.status_code}"
-    except ValueError:
-        error_str = exc.response.content.decode()
-
-    return error_str
-
-
-def paginated_get(
-    response_getter: Callable[..., Response], path: Optional[str] = None, **params
-) -> List[dict]:
-    """Pagination listing"""
-    response_dict = response_getter(path=path, params={**params}).json()
-    items = response_dict["results"]
-    next_cursor = response_dict["next_cursor"]
-
-    while next_cursor is not None:
-        response_dict = response_getter(
-            path=path, params={**params, "cursor": next_cursor}
-        ).json()
-        items.extend(response_dict["results"])
-        next_cursor = response_dict["next_cursor"]
-
-    return items
-
-
-def validate_datetime_format(datetime_str: Optional[str]) -> Optional[str]:
-    if datetime_str is None:
-        return
-
-    try:
-        datetime.strptime(datetime_str, "%Y-%m-%dT%H:%M:%S")
-    except ValueError as exc:
-        raise typer.BadParameter(
-            "The datetime format should be {YYYY}-{MM}-{DD}T{HH}:{MM}:{SS}") from exc
-    return datetime_str
diff --git a/pfcli/utils/__init__.py b/pfcli/utils/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/pfcli/utils/format.py b/pfcli/utils/format.py
new file mode 100644
index 0000000..65437e7
--- /dev/null
+++ b/pfcli/utils/format.py
@@ -0,0 +1,74 @@
+# Copyright (c) 2022-present, FriendliAI Inc. All rights reserved.
+
+"""PeriFlow CLI Formatting Utilities"""
+
+from datetime import datetime, timedelta, timezone
+import os
+
+import typer
+
+
+def datetime_to_pretty_str(past: datetime, long_list: bool = False):
+    cur = datetime.now().astimezone()
+    delta = cur - past
+    if long_list:
+        if delta < timedelta(minutes=1):
+            return f"{delta.seconds % 60}s ago"
+        if delta < timedelta(hours=1):
+            return f"{round((delta.seconds % 3600) / 60)}m {delta.seconds % 60}s ago"
+        elif delta < timedelta(days=1):
+            return f"{delta.seconds // 3600}h {round((delta.seconds % 3600) / 60)}m {delta.seconds % 60}s ago"
+        elif delta < timedelta(days=3):
+            return (
+                f"{delta.days}d {delta.seconds // 3600}h "
+                f"{round((delta.seconds % 3600) / 60)}m ago"
+            )
+        else:
+            return past.astimezone(tz=cur.tzinfo).strftime("%Y-%m-%d %H:%M:%S")
+    else:
+        if delta < timedelta(hours=1):
+            return f"{round((delta.seconds % 3600) / 60)} mins ago"
+        elif delta < timedelta(days=1):
+            return f"{round(delta.seconds / 3600)} hours ago"
+        else:
+            return f"{delta.days + round(delta.seconds / (3600 * 24))} days ago"
+
+
+def timedelta_to_pretty_str(start: datetime, finish: datetime, long_list: bool = False):
+    delta = finish - start
+    if long_list:
+        if delta < timedelta(minutes=1):
+            return f"{(delta.seconds % 60)}s"
+        if delta < timedelta(hours=1):
+            return f"{(delta.seconds % 3600) // 60}m {(delta.seconds % 60)}s"
+        elif delta < timedelta(days=1):
+            return f"{delta.seconds // 3600}h {(delta.seconds % 3600) // 60}m {(delta.seconds % 60)}s"
+        else:
+            return (
+                f"{delta.days}d {delta.seconds // 3600}h "
+                f"{(delta.seconds % 3600) // 60}m {delta.seconds % 60}s"
+            )
+    else:
+        if delta < timedelta(hours=1):
+            return f"{round((delta.seconds % 3600) / 60)} mins"
+        elif delta < timedelta(days=1):
+            return f"{round(delta.seconds / 3600)} hours"
+        else:
+            return f"{delta.days + round(delta.seconds / (3600 * 24))} days"
+
+
+def secho_error_and_exit(text: str, color: str = typer.colors.RED):
+    typer.secho(text, err=True, fg=color)
+    raise typer.Exit(1)
+
+
+def get_remaining_terminal_columns(occupied: int) -> int:
+    return os.get_terminal_size().columns - occupied
+
+
+def utc_to_local(dt: datetime) -> datetime:
+    return dt.replace(tzinfo=timezone.utc).astimezone(tz=None)
+
+
+def datetime_to_simple_string(dt: datetime) -> str:
+    return dt.strftime("%Y-%m-%d %H:%M:%S")
diff --git a/pfcli/utils/fs.py b/pfcli/utils/fs.py
new file mode 100644
index 0000000..50ff68f
--- /dev/null
+++ b/pfcli/utils/fs.py
@@ -0,0 +1,159 @@
+# Copyright (c) 2022-present, FriendliAI Inc. All rights reserved.
+
+"""PeriFlow CLI File System Management Utilities"""
+
+from concurrent.futures import ThreadPoolExecutor, wait, FIRST_EXCEPTION
+from contextlib import contextmanager
+from datetime import datetime
+from dateutil.tz import tzlocal
+import os
+from pathlib import Path
+from typing import List
+import zipfile
+
+import pathspec
+import requests
+from tqdm import tqdm
+from tqdm.utils import CallbackIOWrapper
+import typer
+
+from pfcli.utils.format import secho_error_and_exit
+
+periflow_directory = Path.home() / ".periflow"
+
+
+def get_periflow_directory() -> Path:
+    periflow_directory.mkdir(exist_ok=True)
+    return periflow_directory
+
+
+@contextmanager
+def zip_dir(base_path: Path, target_files: List[Path], zip_path: Path):
+    with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_STORED) as zip_file:
+        for file in target_files:
+            zip_file.write(file, file.relative_to(base_path.parent))
+    typer.secho("Uploading workspace directory...", fg=typer.colors.MAGENTA)
+    try:
+        yield zip_path.open("rb")
+    finally:
+        zip_path.unlink()
+
+
+def get_workspace_files(dir_path: Path) -> List[Path]:
+    ignore_file = dir_path / ".pfignore"
+    all_files = set(x for x in dir_path.rglob("*") if x.is_file() and x != ignore_file)
+
+    if not ignore_file.exists():
+        return list(all_files)
+
+    with open(ignore_file, "r", encoding="utf-8") as f:
+        ignore_patterns = f.read()
+    spec = pathspec.PathSpec.from_lines(
+        pathspec.patterns.GitWildMatchPattern, ignore_patterns.splitlines()
+    )
+    matched_files = set(dir_path / x for x in spec.match_tree_files(dir_path))  # type: ignore
+    return list(all_files.difference(matched_files))
+
+
+def storage_path_to_local_path(
+    storage_path: str, source_path: Path, expand: bool
+) -> str:
+    if source_path.is_file():
+        return str(source_path)
+
+    if expand:
+        return str(source_path / Path(storage_path))
+
+    return str(source_path / Path(storage_path.split("/", 1)[1]))
+
+
+def get_file_info(storage_path: str, source_path: Path, expand: bool) -> dict:
+    loacl_path = storage_path_to_local_path(storage_path, source_path, expand)
+    return {
+        "name": os.path.basename(storage_path),
+        "path": storage_path,
+        "mtime": datetime.fromtimestamp(
+            os.stat(loacl_path).st_mtime, tz=tzlocal()
+        ).isoformat(),
+        "size": os.stat(loacl_path).st_size,
+    }
+
+
+def get_content_size(url: str) -> int:
+    response = requests.get(url, stream=True)
+    if response.status_code != 200:
+        secho_error_and_exit("Failed to download (invalid url)")
+    return int(response.headers["Content-Length"])
+
+
+def download_range(url: str, start: int, end: int, output: str, ctx: tqdm) -> None:
+    headers = {"Range": f"bytes={start}-{end}"}
+    response = requests.get(url, headers=headers, stream=True)
+
+    with open(output, "wb") as f:
+        wrapped_object = CallbackIOWrapper(ctx.update, f, "write")
+        for part in response.iter_content(1024):
+            wrapped_object.write(part)
+
+
+def download_file_simple(url: str, out: str, content_length: int) -> None:
+    response = requests.get(url, stream=True)
+    with tqdm.wrapattr(
+        open(out, "wb"), "write", miniters=1, total=content_length
+    ) as fout:
+        for chunk in response.iter_content(chunk_size=4096):
+            fout.write(chunk)
+
+
+def download_file_parallel(
+    url: str, out: str, content_length: int, chunk_size: int = 1024 * 1024 * 4
+) -> None:
+    chunks = range(0, content_length, chunk_size)
+
+    temp_out_prefix = os.path.join(os.path.dirname(out), f".{os.path.basename(out)}")
+
+    try:
+        with tqdm(
+            total=content_length, unit="B", unit_scale=True, unit_divisor=1024
+        ) as t:
+            with ThreadPoolExecutor() as executor:
+                futs = [
+                    executor.submit(
+                        download_range,
+                        url,
+                        start,
+                        start + chunk_size - 1,
+                        f"{temp_out_prefix}.part{i}",
+                        t,
+                    )
+                    for i, start in enumerate(chunks)
+                ]
+                wait(futs, return_when=FIRST_EXCEPTION)
+
+        # Merge partitioned files
+        with open(out, "wb") as f:
+            for i in range(len(chunks)):
+                chunk_path = f"{temp_out_prefix}.part{i}"
+                with open(chunk_path, "rb") as chunk_f:
+                    f.write(chunk_f.read())
+
+                os.remove(chunk_path)
+    finally:
+        # Clean up zombie temporary partitioned files
+        for i in range(len(chunks)):
+            chunk_path = f"{temp_out_prefix}.part{i}"
+            if os.path.isfile(chunk_path):
+                os.remove(chunk_path)
+
+
+def download_file(url: str, out: str) -> None:
+    file_size = get_content_size(url)
+
+    # Create directory if not exists
+    dirpath = os.path.dirname(out)
+    os.makedirs(dirpath, exist_ok=True)
+
+    if file_size >= 16 * 1024 * 1024:
+        download_file_parallel(url, out, file_size)
+    else:
+        download_file_simple(url, out, file_size)
diff --git a/pfcli/utils/prompt.py b/pfcli/utils/prompt.py
new file mode 100644
index 0000000..834a14c
--- /dev/null
+++ b/pfcli/utils/prompt.py
@@ -0,0 +1,21 @@
+# Copyright (c) 2022-present, FriendliAI Inc. All rights reserved.
+
+"""PeriFlow CLI Interactive Prompt Utilities"""
+
+from typing import Optional
+from subprocess import CalledProcessError, check_call
+import os
+
+import typer
+
+
+def open_editor(path: str, editor: Optional[str] = None):
+    default_editor = editor or get_default_editor()
+    try:
+        check_call([default_editor, path])
+    except CalledProcessError:
+        typer.secho("", fg=typer.colors.RED)
+
+
+def get_default_editor() -> str:
+    return os.environ.get("PERIFLOW_CLI_EDITOR", "vim")
diff --git a/pfcli/utils/request.py b/pfcli/utils/request.py
new file mode 100644
index 0000000..bcbe8d2
--- /dev/null
+++ b/pfcli/utils/request.py
@@ -0,0 +1,52 @@
+# Copyright (c) 2022-present, FriendliAI Inc. All rights reserved.
+
+"""PeriFlow CLI API Request Utilities"""
+
+from typing import Callable, List, Optional
+
+from requests.exceptions import HTTPError
+from requests.models import Response
+
+from pfcli.utils.url import periflow_discuss_url
+
+
+def decode_http_err(exc: HTTPError) -> str:
+    try:
+        if exc.response.status_code == 500:
+            error_str = f"Internal Server Error: Please contact to system admin via {periflow_discuss_url}"
+        elif exc.response.status_code == 404:
+            error_str = (
+                "Not Found: The requested resource is not found. Please check it again. "
+                f"If you cannot find out why this error occurs, please visit {periflow_discuss_url}."
+            )
+        else:
+            response = exc.response
+            detail_json = response.json()
+            if "detail" in detail_json:
+                error_str = f"Error Code: {response.status_code}\nDetail: {detail_json['detail']}"
+            elif "error_description" in detail_json:
+                error_str = f"Error Code: {response.status_code}\nDetail: {detail_json['error_description']}"
+            else:
+                error_str = f"Error Code: {response.status_code}"
+    except ValueError:
+        error_str = exc.response.content.decode()
+
+    return error_str
+
+
+def paginated_get(
+    response_getter: Callable[..., Response], path: Optional[str] = None, **params
+) -> List[dict]:
+    """Pagination listing"""
+    response_dict = response_getter(path=path, params={**params}).json()
+    items = response_dict["results"]
+    next_cursor = response_dict["next_cursor"]
+
+    while next_cursor is not None:
+        response_dict = response_getter(
+            path=path, params={**params, "cursor": next_cursor}
+        ).json()
+        items.extend(response_dict["results"])
+        next_cursor = response_dict["next_cursor"]
+
+    return items
diff --git a/pfcli/utils/url.py b/pfcli/utils/url.py
new file mode 100644
index 0000000..e5e5b5f
--- /dev/null
+++ b/pfcli/utils/url.py
@@ -0,0 +1,42 @@
+# Copyright (c) 2022-present, FriendliAI Inc. All rights reserved.
+
+"""PeriFlow CLI URL Utilities"""
+
+from urllib.parse import urljoin
+
+periflow_api_server = "https://api-staging.friendli.ai/api/"
+periflow_ws_server = "wss://api-ws-staging.friendli.ai/ws/"
+periflow_discuss_url = "https://discuss.friendli.ai/"
+periflow_mr_server = "https://pfmodelregistry-staging.friendli.ai/"
+periflow_serve_server = "http://0.0.0.0:8000/"
+periflow_auth_server = "https://pfauth-staging.friendli.ai/"
+periflow_meter_server = "https://pfmeter-staging.friendli.ai/"
+periflow_observatory_server = "https://pfo-staging.friendli.ai/"
+
+
+def get_auth_uri(path: str) -> str:
+    return urljoin(periflow_auth_server, path)
+
+
+def get_uri(path: str) -> str:
+    return urljoin(periflow_api_server, path)
+
+
+def get_wss_uri(path: str) -> str:
+    return urljoin(periflow_ws_server, path)
+
+
+def get_pfs_uri(path: str) -> str:
+    return urljoin(periflow_serve_server, path)
+
+
+def get_mr_uri(path: str) -> str:
+    return urljoin(periflow_mr_server, path)
+
+
+def get_meter_uri(path: str) -> str:
+    return urljoin(periflow_meter_server, path)
+
+
+def get_observatory_uri(path: str) -> str:
+    return urljoin(periflow_observatory_server, path)
diff --git a/pfcli/utils/validate.py b/pfcli/utils/validate.py
new file mode 100644
index 0000000..881ddf7
--- /dev/null
+++ b/pfcli/utils/validate.py
@@ -0,0 +1,57 @@
+# Copyright (c) 2022-present, FriendliAI Inc. All rights reserved.
+
+"""PeriFlow CLI Validation Utilities"""
+
+from datetime import datetime
+from typing import List, Optional
+
+import typer
+
+from pfcli.service import CloudType, cloud_region_map, StorageType, storage_region_map
+from pfcli.utils.format import secho_error_and_exit
+
+
+def validate_storage_region(vendor: StorageType, region: str):
+    available_regions = storage_region_map[vendor]
+    if region not in available_regions:
+        secho_error_and_exit(
+            f"'{region}' is not supported region for {vendor}. Please choose another one in {available_regions}."
+        )
+
+
+def validate_cloud_region(vendor: CloudType, region: str):
+    available_regions = cloud_region_map[vendor]
+    if region not in available_regions:
+        secho_error_and_exit(
+            f"'{region}' is not supported region for {vendor}. Please choose another one in {available_regions}."
+        )
+
+
+def validate_datetime_format(datetime_str: Optional[str]) -> Optional[str]:
+    if datetime_str is None:
+        return
+
+    try:
+        datetime.strptime(datetime_str, "%Y-%m-%dT%H:%M:%S")
+    except ValueError as exc:
+        raise typer.BadParameter(
+            "The datetime format should be {YYYY}-{MM}-{DD}T{HH}:{MM}:{SS}"
+        ) from exc
+    return datetime_str
+
+
+def validate_cloud_storage_type(val: StorageType) -> str:
+    if val is StorageType.FAI:
+        secho_error_and_exit(
+            "Checkpoint creation with FAI storage is not supported now."
+        )
+    return val.value
+
+
+def validate_parallelism_order(value: str) -> List[str]:
+    parallelism_order = value.split(",")
+    if {"pp", "dp", "mp"} != set(parallelism_order):
+        secho_error_and_exit(
+            "Invalid Argument: parallelism_order should contain 'pp', 'dp', 'mp'"
+        )
+    return parallelism_order
diff --git a/pfcli/vm.py b/pfcli/vm.py
index a7b190c..66ddda6 100644
--- a/pfcli/vm.py
+++ b/pfcli/vm.py
@@ -6,16 +6,18 @@
 
 import typer
 
-from pfcli.service import CloudType, ServiceType
+from pfcli.service import CloudType, PeriFlowService, ServiceType
 from pfcli.service.client import (
-    GroupVMConfigClientService,
-    ProjectVMQuotaClientService,
+    GroupProjectClientService,
+    GroupProjectVMQuotaClientService,
+    PFTGroupVMConfigClientService,
+    PFTProjectVMConfigClientService,
+    PFTProjectVMQuotaClientService,
     build_client,
 )
-from pfcli.service.client.project import ProjectVMConfigClientService
-from pfcli.service.formatter import TableFormatter
-from pfcli.utils import validate_cloud_region
-from pfcli.settings import settings
+from pfcli.service.client.project import PFTProjectVMConfigClientService, find_project_id
+from pfcli.service.formatter import PanelFormatter, TableFormatter
+from pfcli.utils.format import secho_error_and_exit
 
 
 app = typer.Typer(
@@ -23,6 +25,13 @@
     context_settings={"help_option_names": ["-h", "--help"]},
     add_completion=False,
 )
+quota_app = typer.Typer(
+    no_args_is_help=True,
+    context_settings={"help_option_names": ["-h", "--help"]},
+    add_completion=False,
+)
+
+app.add_typer(quota_app, name="quota", help="Manage quota.")
 
 formatter = TableFormatter(
     name="VM Instances",
@@ -49,10 +58,74 @@
         "Spot",
     ],
 )
+quota_detail_panel = PanelFormatter(
+    name="VM Quota",
+    fields=[
+        "code",
+        "in_use",
+        "project_limit",
+        "organization_limit",
+    ],
+    headers=[
+        "VM",
+        "In Use",
+        "Project Limit",
+        "Organization Limit",
+    ],
+)
+
+
+def vm_list_for_train(cloud: Optional[CloudType], device_type: Optional[str]) -> None:
+    """List all VM information for training."""
+    vm_quota_client: PFTProjectVMQuotaClientService = build_client(
+        ServiceType.PFT_PROJECT_VM_QUOTA
+    )
+    vm_config_client: PFTProjectVMConfigClientService = build_client(
+        ServiceType.PFT_PROJECT_VM_CONFIG
+    )
+    group_vm_config_client: PFTGroupVMConfigClientService = build_client(
+        ServiceType.PFT_GROUP_VM_CONFIG
+    )
+
+    vm_dict_list = vm_quota_client.list_vm_quotas(
+        vendor=cloud, device_type=device_type
+    )
+    vm_id_map = group_vm_config_client.get_vm_config_id_map()
+    available_vm_dict_list = []
+    for vm_dict in vm_dict_list:
+        vm_instance_name = vm_dict["vm_config_type"]["code"]
+        try:
+            vm_count_in_use = vm_config_client.get_vm_count_in_use(
+                vm_id_map[vm_instance_name]
+            )
+            vm_dict[
+                "quota"
+            ] = f"{vm_dict['quota'] - vm_count_in_use} / {vm_dict['quota']}"
+        except KeyError:
+            continue
+        available_vm_dict_list.append(vm_dict)
+
+    available_vm_dict_list = sorted(
+        available_vm_dict_list, key=lambda d: d["vm_config_type"]["code"]
+    )
+    formatter.render(available_vm_dict_list)
 
 
-@app.command("list")
+def vm_list_for_serve(cloud: Optional[CloudType], device_type: Optional[str]) -> None:
+    # TODO: FILL ME
+    secho_error_and_exit(
+        "VM list for the deployment is not supported yet. Please contact the support team."
+    )
+
+
+@app.command("list", help="list up available VMs")
 def list(
+    service: PeriFlowService = typer.Option(
+        ...,
+        "--service",
+        "-s",
+        help="PeriFlow service type. Training and serving services are provided with different VM types.",
+    ),
     cloud: Optional[CloudType] = typer.Option(
         None, "--cloud", "-c", help="Filter list by cloud vendor."
     ),
@@ -60,39 +133,117 @@ def list(
         None, "--device-type", "-d", help="Filter list by device type."
     ),
 ):
-    if not settings.pfs_only:
-        """List all VM quota information."""
-        vm_quota_client: ProjectVMQuotaClientService = build_client(
-            ServiceType.PROJECT_VM_QUOTA
-        )
-        vm_config_client: ProjectVMConfigClientService = build_client(
-            ServiceType.PROJECT_VM_CONFIG
-        )
-        group_vm_config_client: GroupVMConfigClientService = build_client(
-            ServiceType.GROUP_VM_CONFIG
-        )
-
-        vm_dict_list = vm_quota_client.list_vm_quotas(vendor=cloud, device_type=device_type)
-        vm_id_map = group_vm_config_client.get_vm_config_id_map()
-        available_vm_dict_list = []
-        for vm_dict in vm_dict_list:
-            vm_instance_name = vm_dict["vm_config_type"]["code"]
-            try:
-                vm_count_in_use = vm_config_client.get_vm_count_in_use(
-                    vm_id_map[vm_instance_name]
-                )
-                vm_dict[
-                    "quota"
-                ] = f"{vm_dict['quota'] - vm_count_in_use} / {vm_dict['quota']}"
-            except KeyError:
-                continue
-            available_vm_dict_list.append(vm_dict)
-
-        available_vm_dict_list = sorted(
-            available_vm_dict_list, key=lambda d: d["vm_config_type"]["code"]
-        )
-
-        formatter.render(available_vm_dict_list)
-    else:
-        #TODO: PFS vm list
-        pass
+    handler_map = {
+        PeriFlowService.TRAIN: vm_list_for_train,
+        PeriFlowService.SERVE: vm_list_for_serve,
+    }
+    handler_map[service](cloud, device_type)
+
+
+@quota_app.command("view", help="view quota detail of a VM")
+def view(
+    vm_instance_name: str = typer.Argument(..., help="vm type"),
+):
+    vm_quota_client: PFTProjectVMQuotaClientService = build_client(
+        ServiceType.PFT_PROJECT_VM_QUOTA
+    )
+    vm_config_client: PFTProjectVMConfigClientService = build_client(
+        ServiceType.PFT_PROJECT_VM_CONFIG
+    )
+    group_vm_config_client: PFTGroupVMConfigClientService = build_client(
+        ServiceType.PFT_GROUP_VM_CONFIG
+    )
+
+    vm_dict_list = vm_quota_client.list_vm_quotas()
+    vm_id_map = group_vm_config_client.get_vm_config_id_map()
+    for vm_dict in vm_dict_list:
+        if vm_instance_name == vm_dict["vm_config_type"]["code"]:
+            vm_count_in_use = vm_config_client.get_vm_count_in_use(
+                vm_id_map[vm_instance_name]
+            )
+            panel_dict = {
+                "code": vm_instance_name,
+                "in_use": vm_count_in_use,
+                "project_limit": "-"
+                if vm_dict["quota"] == vm_dict["global_quota"]
+                else vm_dict["quota"],
+                "organization_limit": vm_dict["global_quota"],
+            }
+            quota_detail_panel.render(panel_dict)
+            return
+
+    secho_error_and_exit(f"VM instance {vm_instance_name} does not exist")
+
+
+@quota_app.command("create", help="Create quota limitation of the project")
+def create(
+    vm_instance_name: str = typer.Option(..., help="vm type"),
+    project_name: str = typer.Option(
+        ..., help="name of the project where quota will be created"
+    ),
+    quota: int = typer.Option(..., help="number of VM quota"),
+):
+    project_client: GroupProjectClientService = build_client(ServiceType.GROUP_PROJECT)
+    project_id = find_project_id(project_client.list_projects(), project_name)
+
+    vm_quota_client: GroupProjectVMQuotaClientService = build_client(
+        ServiceType.GROUP_VM_QUOTA
+    )
+
+    vm_quota_client.create_project_quota(vm_instance_name, project_id, quota)
+    typer.secho(
+        f"VM quota of VM {vm_instance_name} in project {project_name} is set to {quota}!",
+        fg=typer.colors.BLUE,
+    )
+
+
+@quota_app.command("update", help="Update quota limitation of the project")
+def update(
+    vm_instance_name: str = typer.Option(..., help="vm type"),
+    project_name: str = typer.Option(
+        ..., help="name of the project where quota will be created"
+    ),
+    quota: int = typer.Option(..., help="number of VM quota"),
+):
+    project_client: GroupProjectClientService = build_client(ServiceType.GROUP_PROJECT)
+    project_id = find_project_id(project_client.list_projects(), project_name)
+
+    vm_quota_client: GroupProjectVMQuotaClientService = build_client(
+        ServiceType.GROUP_VM_QUOTA
+    )
+    quotas = vm_quota_client.list_quota(vm_instance_name, project_id)
+    if not quotas:
+        secho_error_and_exit(f"Cannot find project quota of vm {vm_instance_name}")
+
+    # unique
+    quota_id = quotas[0]["id"]
+    vm_quota_client.update_project_quota(quota_id, quota)
+    typer.secho(
+        f"VM quota of VM {vm_instance_name} in project {project_name} is updated to {quota}!",
+        fg=typer.colors.BLUE,
+    )
+
+
+@quota_app.command("delete", help="Delete quota limitation of the project")
+def delete(
+    vm_instance_name: str = typer.Option(..., help="vm type"),
+    project_name: str = typer.Option(
+        ..., help="name of the project where quota will be created"
+    ),
+):
+    project_client: GroupProjectClientService = build_client(ServiceType.GROUP_PROJECT)
+    project_id = find_project_id(project_client.list_projects(), project_name)
+    vm_quota_client: GroupProjectVMQuotaClientService = build_client(
+        ServiceType.GROUP_VM_QUOTA
+    )
+    quotas = vm_quota_client.list_quota(vm_instance_name, project_id)
+    if not quotas:
+        secho_error_and_exit(f"Cannot find project quota of vm {vm_instance_name}")
+
+    # unique
+    quota_id = quotas[0]["id"]
+    vm_quota_client.delete_quota(quota_id)
+    typer.secho(
+        f"VM quota of VM {vm_instance_name} in project {project_name} is deleted!",
+        fg=typer.colors.BLUE,
+    )


Test patch:
diff --git a/test/conftest.py b/test/conftest.py
index beafe78..70d36f8 100644
--- a/test/conftest.py
+++ b/test/conftest.py
@@ -6,7 +6,7 @@
 import pytest
 import requests_mock
 
-from pfcli.utils import get_uri
+from pfcli.utils.url import get_uri
 
 
 @pytest.fixture
diff --git a/test/service/client/test_billing.py b/test/service/client/test_billing.py
index 7cb6582..c037889 100644
--- a/test/service/client/test_billing.py
+++ b/test/service/client/test_billing.py
@@ -1,7 +1,7 @@
 # Copyright (C) 2021 FriendliAI
 
-import uuid
 from copy import deepcopy
+from datetime import datetime, timedelta
 
 import pytest
 import requests_mock
@@ -9,40 +9,44 @@
 
 from pfcli.service import ServiceType
 from pfcli.service.client import build_client
-from pfcli.service.client.billing import BillingClientService
+from pfcli.service.client.billing import PFTBillingClientService
 
 
 @pytest.fixture
-def billing_summary_client() -> BillingClientService:
-    return build_client(ServiceType.BILLING_SUMMARY)
+def billing_summary_client(user_project_group_context) -> PFTBillingClientService:
+    return build_client(ServiceType.PFT_BILLING_SUMMARY)
 
 
 @pytest.mark.usefixtures("patch_auto_token_refresh")
 def test_billing_summary_client_get_summary(
-    requests_mock: requests_mock.Mocker, billing_summary_client: BillingClientService
+    requests_mock: requests_mock.Mocker, billing_summary_client: PFTBillingClientService
 ):
-    assert isinstance(billing_summary_client, BillingClientService)
+    assert isinstance(billing_summary_client, PFTBillingClientService)
 
     url_template = deepcopy(billing_summary_client.url_template)
 
+    now = datetime.now()
+    ten_days_after = now + timedelta(days=10)
     # Success
     requests_mock.get(
         url_template.render(),
         json={
             "results": [
                 {
-                    "instance": {"id": "some_id", "attributes": {"vm_name": "vm_1"}},
-                    "price": 1.557,
+                    "start_time": now.isoformat(),
+                    "end_time": ten_days_after.isoformat(),
+                    "price_list": [{"agg_unit": "user_id", "id": "some_id", "price": 1.557}],
                 }
             ],
-            "next_cursor": None,
         },
     )
-    assert billing_summary_client.list_prices(year=2022, month=6, group_id=uuid.uuid4())
+    assert billing_summary_client.list_prices(start_date=now.isoformat(),
+                                              end_date=ten_days_after.isoformat())
 
     # Failed due to HTTP error
     requests_mock.get(url_template.render(), status_code=404)
     with pytest.raises(typer.Exit):
         assert billing_summary_client.list_prices(
-            year=2022, month=6, group_id=uuid.uuid4()
+            start_date=now.isoformat(),
+            end_date=ten_days_after.isoformat()
         )
diff --git a/test/service/client/test_experiment.py b/test/service/client/test_experiment.py
deleted file mode 100644
index 913c4c1..0000000
--- a/test/service/client/test_experiment.py
+++ /dev/null
@@ -1,83 +0,0 @@
-# Copyright (C) 2022 FriendliAI
-
-"""Test ExperimentClient Service"""
-
-
-from copy import deepcopy
-
-import pytest
-import requests_mock
-import typer
-
-from pfcli.service import ServiceType
-from pfcli.service.client import build_client
-from pfcli.service.client.experiment import ExperimentClientService
-
-
-@pytest.fixture
-def experiment_client() -> ExperimentClientService:
-    return build_client(ServiceType.EXPERIMENT)
-
-
-@pytest.mark.usefixtures("patch_auto_token_refresh")
-def test_experiment_client_list_jobs_in_experiment(
-    requests_mock: requests_mock.Mocker, experiment_client: ExperimentClientService
-):
-    assert isinstance(experiment_client, ExperimentClientService)
-
-    # Success
-    url_template = deepcopy(experiment_client.url_template)
-    url_template.attach_pattern("$experiment_id/job/")
-    requests_mock.get(
-        url_template.render(experiment_id=1), json={"results": [{"id": 0}, {"id": 1}]}
-    )
-    assert experiment_client.list_jobs_in_experiment(1) == [{"id": 0}, {"id": 1}]
-
-    # Failed due to HTTP error
-    requests_mock.get(url_template.render(experiment_id=1), status_code=404)
-    with pytest.raises(typer.Exit):
-        experiment_client.list_jobs_in_experiment(1)
-
-
-@pytest.mark.usefixtures("patch_auto_token_refresh")
-def test_experiment_client_delete_experiment(
-    requests_mock: requests_mock.Mocker, experiment_client: ExperimentClientService
-):
-    assert isinstance(experiment_client, ExperimentClientService)
-
-    # Success
-    url_template = deepcopy(experiment_client.url_template)
-    url_template.attach_pattern("$experiment_id/")
-    requests_mock.delete(url_template.render(experiment_id=1), status_code=204)
-    try:
-        experiment_client.delete_experiment(1)
-    except typer.Exit:
-        raise pytest.fail("Test delete experiment failed.")
-
-    # Failed due to HTTP error
-    requests_mock.delete(url_template.render(experiment_id=1), status_code=404)
-    with pytest.raises(typer.Exit):
-        experiment_client.delete_experiment(1)
-
-
-@pytest.mark.usefixtures("patch_auto_token_refresh")
-def test_experiment_client_update_experiment(
-    requests_mock: requests_mock.Mocker, experiment_client: ExperimentClientService
-):
-    assert isinstance(experiment_client, ExperimentClientService)
-
-    # Success
-    url_template = deepcopy(experiment_client.url_template)
-    url_template.attach_pattern("$experiment_id/")
-    requests_mock.patch(
-        url_template.render(experiment_id=1), json={"id": 0, "name": "my-exp"}
-    )
-    assert experiment_client.update_experiment_name(1, "my-exp") == {
-        "id": 0,
-        "name": "my-exp",
-    }
-
-    # Failed due to HTTP error
-    requests_mock.patch(url_template.render(experiment_id=1), status_code=404)
-    with pytest.raises(typer.Exit):
-        experiment_client.update_experiment_name(1, "new-exp")
diff --git a/test/service/client/test_group.py b/test/service/client/test_group.py
index 5323f45..5adb061 100644
--- a/test/service/client/test_group.py
+++ b/test/service/client/test_group.py
@@ -20,7 +20,8 @@
 from pfcli.service.client.group import (
     GroupClientService,
     GroupProjectCheckpointClientService,
-    GroupVMConfigClientService,
+    GroupProjectVMQuotaClientService,
+    PFTGroupVMConfigClientService,
 )
 
 
@@ -30,8 +31,8 @@ def group_client(user_project_group_context) -> GroupClientService:
 
 
 @pytest.fixture
-def group_vm_config_client(user_project_group_context) -> GroupVMConfigClientService:
-    return build_client(ServiceType.GROUP_VM_CONFIG)
+def group_vm_config_client(user_project_group_context) -> PFTGroupVMConfigClientService:
+    return build_client(ServiceType.PFT_GROUP_VM_CONFIG)
 
 
 @pytest.fixture
@@ -41,6 +42,13 @@ def group_project_checkpoint_client(
     return build_client(ServiceType.GROUP_PROJECT_CHECKPOINT)
 
 
+@pytest.fixture
+def group_project_vm_quota_client(
+    user_project_group_context,
+) -> GroupProjectVMQuotaClientService:
+    return build_client(ServiceType.GROUP_VM_QUOTA)
+
+
 @pytest.mark.usefixtures("patch_auto_token_refresh")
 def test_group_client_create_group(
     requests_mock: requests_mock.Mocker, group_client: GroupClientService
@@ -134,7 +142,7 @@ def test_group_client_accept_invite(
 @pytest.mark.usefixtures("patch_auto_token_refresh")
 def test_group_vm_config_client_get_id_by_name(
     requests_mock: requests_mock.Mocker,
-    group_vm_config_client: GroupVMConfigClientService,
+    group_vm_config_client: PFTGroupVMConfigClientService,
 ):
     example_data = [
         {
@@ -192,9 +200,9 @@ def test_group_vm_config_client_get_id_by_name(
 @pytest.mark.usefixtures("patch_auto_token_refresh")
 def test_group_vm_config_client_get_vm_config_id_map(
     requests_mock: requests_mock.Mocker,
-    group_vm_config_client: GroupVMConfigClientService,
+    group_vm_config_client: PFTGroupVMConfigClientService,
 ):
-    assert isinstance(group_vm_config_client, GroupVMConfigClientService)
+    assert isinstance(group_vm_config_client, PFTGroupVMConfigClientService)
 
     # Success
     requests_mock.get(
@@ -428,3 +436,35 @@ def test_group_checkpoint_create_checkpoints(
             data_config={"k": "v"},
             job_setting_config={"k": "v"},
         )
+
+
+@pytest.mark.usefixtures("patch_auto_token_refresh")
+def test_group_project_vm_quota_client(
+    requests_mock: requests_mock.Mocker,
+    group_project_vm_quota_client: GroupProjectVMQuotaClientService
+):
+    data = {
+        "id": 1,
+        "project_id": "11111111-1111-1111-1111-111111111111",
+        "vm_config": {
+            "vm_config_type": {
+                "id": 0,
+                "code": "azure-v100",
+                "vendor": "azure",
+                "region": "eastus",
+                "device_type": "V100",
+            },
+            "group_id": "00000000-0000-0000-0000-000000000000",
+        },
+        "quota": 10,
+    }
+    url = group_project_vm_quota_client.url_template.render(
+        **group_project_vm_quota_client.url_kwargs
+    )
+    requests_mock.post(url, json=data)
+
+    assert group_project_vm_quota_client.create_project_quota(
+        "azure-v100",
+        uuid.UUID("11111111-1111-1111-1111-111111111111"),
+        10
+    ) == data
diff --git a/test/service/client/test_job.py b/test/service/client/test_job.py
index c9ea769..de72227 100644
--- a/test/service/client/test_job.py
+++ b/test/service/client/test_job.py
@@ -5,6 +5,8 @@
 
 import json
 from copy import deepcopy
+from pathlib import Path
+from tempfile import TemporaryDirectory
 from unittest.mock import AsyncMock, patch
 
 import pytest
@@ -16,27 +18,27 @@
 from pfcli.service.auth import TokenType
 from pfcli.service.client import build_client
 from pfcli.service.client.job import (
-    JobArtifactClientService,
-    JobCheckpointClientService,
-    JobClientService,
     JobTemplateClientService,
     JobWebSocketClientService,
+    ProjectJobArtifactClientService,
+    ProjectJobCheckpointClientService,
+    ProjectJobClientService,
 )
 
 
 @pytest.fixture
-def job_client() -> JobClientService:
-    return build_client(ServiceType.JOB)
+def project_job_client(user_project_group_context) -> ProjectJobClientService:
+    return build_client(ServiceType.PROJECT_JOB)
 
 
 @pytest.fixture
-def job_checkpoint_client() -> JobCheckpointClientService:
-    return build_client(ServiceType.JOB_CHECKPOINT, job_id=1)
+def job_checkpoint_client(user_project_group_context) -> ProjectJobCheckpointClientService:
+    return build_client(ServiceType.PROJECT_JOB_CHECKPOINT, job_number=1)
 
 
 @pytest.fixture
-def job_artifact_client() -> JobArtifactClientService:
-    return build_client(ServiceType.JOB_ARTIFACT, job_id=1)
+def job_artifact_client(user_project_group_context) -> ProjectJobArtifactClientService:
+    return build_client(ServiceType.PROJECT_JOB_ARTIFACT, job_number=1)
 
 
 @pytest.fixture
@@ -45,91 +47,113 @@ def job_template_client() -> JobTemplateClientService:
 
 
 @pytest.fixture
-def job_ws_client() -> JobWebSocketClientService:
+def job_ws_client(user_project_group_context) -> JobWebSocketClientService:
     return build_client(ServiceType.JOB_WS)
 
 
 @pytest.mark.usefixtures("patch_auto_token_refresh")
 def test_job_client_list_jobs(
-    requests_mock: requests_mock.Mocker, job_client: JobClientService
+    requests_mock: requests_mock.Mocker, project_job_client: ProjectJobClientService
 ):
-    assert isinstance(job_client, JobClientService)
+    assert isinstance(project_job_client, ProjectJobClientService)
 
     # Success
     requests_mock.get(
-        job_client.url_template.render(),
+        project_job_client.url_template.render(
+            **project_job_client.url_kwargs
+        ),
         json={"results": [{"id": 1}, {"id": 2}], "next_cursor": None},
     )
-    assert job_client.list_jobs() == [{"id": 1}, {"id": 2}]
+    assert project_job_client.list_jobs() == [{"id": 1}, {"id": 2}]
 
     # Failed due to HTTP error
-    requests_mock.get(job_client.url_template.render(), status_code=404)
+    requests_mock.get(project_job_client.url_template.render(
+        **project_job_client.url_kwargs
+    ), status_code=404)
     with pytest.raises(typer.Exit):
-        job_client.list_jobs()
+        project_job_client.list_jobs()
 
 
 @pytest.mark.usefixtures("patch_auto_token_refresh")
 def test_job_client_get_job(
-    requests_mock: requests_mock.Mocker, job_client: JobClientService
+    requests_mock: requests_mock.Mocker, project_job_client: ProjectJobClientService
 ):
-    assert isinstance(job_client, JobClientService)
+    assert isinstance(project_job_client, ProjectJobClientService)
 
     # Success
-    requests_mock.get(job_client.url_template.render(1), json={"id": 1})
-    assert job_client.get_job(1) == {"id": 1}
+    requests_mock.get(project_job_client.url_template.render(
+        **project_job_client.url_kwargs,
+        pk=1
+    ), json={"id": 1})
+    assert project_job_client.get_job(1) == {"id": 1}
 
     # Failed due to HTTP error
-    requests_mock.get(job_client.url_template.render(1), status_code=404)
+    requests_mock.get(project_job_client.url_template.render(
+        **project_job_client.url_kwargs,
+        pk=1
+    ), status_code=404)
     with pytest.raises(typer.Exit):
-        job_client.get_job(1)
+        project_job_client.get_job(1)
 
 
 @pytest.mark.usefixtures("patch_auto_token_refresh")
 def test_job_client_cancel_job(
-    requests_mock: requests_mock.Mocker, job_client: JobClientService
+    requests_mock: requests_mock.Mocker, project_job_client: ProjectJobClientService
 ):
-    url_template = deepcopy(job_client.url_template)
-    url_template.attach_pattern("$job_id/cancel/")
+    url_template = deepcopy(project_job_client.url_template)
+    url_template.attach_pattern("$job_number/cancel/")
 
     # Success
-    requests_mock.post(url_template.render(job_id=1))
+    requests_mock.post(url_template.render(
+        **project_job_client.url_kwargs,
+        job_number=1,
+    ))
     try:
-        job_client.cancel_job(1)
+        project_job_client.cancel_job(1)
     except typer.Exit as exc:
-        raise pytest.failed(f"Test failed: {exc!r}") from exc
+        raise pytest.fail(f"Test failed: {exc!r}") from exc
 
     # Failed
-    requests_mock.post(url_template.render(job_id=1), status_code=500)
+    requests_mock.post(url_template.render(
+        **project_job_client.url_kwargs,
+        job_number=1
+    ), status_code=500)
     with pytest.raises(typer.Exit):
-        job_client.cancel_job(1)
+        project_job_client.cancel_job(1)
 
 
 @pytest.mark.usefixtures("patch_auto_token_refresh")
 def test_job_client_terminate_job(
-    requests_mock: requests_mock.Mocker, job_client: JobClientService
+    requests_mock: requests_mock.Mocker, project_job_client: ProjectJobClientService
 ):
-    url_template = deepcopy(job_client.url_template)
-    url_template.attach_pattern("$job_id/terminate/")
+    url_template = deepcopy(project_job_client.url_template)
+    url_template.attach_pattern("$job_number/terminate/")
 
     # Success
-    requests_mock.post(url_template.render(job_id=1))
+    requests_mock.post(url_template.render(
+        **project_job_client.url_kwargs,
+        job_number=1,
+    ))
     try:
-        job_client.terminate_job(1)
+        project_job_client.terminate_job(1)
     except typer.Exit as exc:
-        raise pytest.failed(f"Test failed: {exc!r}") from exc
+        raise pytest.fail(f"Test failed: {exc!r}") from exc
 
     # Failed
-    requests_mock.post(url_template.render(job_id=1), status_code=500)
+    requests_mock.post(url_template.render(
+        **project_job_client.url_kwargs,
+        job_number=1,
+    ), status_code=500)
     with pytest.raises(typer.Exit):
-        job_client.terminate_job(1)
+        project_job_client.terminate_job(1)
 
 
 @pytest.mark.usefixtures("patch_auto_token_refresh")
 def test_job_client_get_text_logs(
-    requests_mock: requests_mock.Mocker, job_client: JobClientService
+    requests_mock: requests_mock.Mocker, project_job_client: ProjectJobClientService
 ):
-    url_template = deepcopy(job_client.url_template)
-    url_template.attach_pattern("$job_id/text_log/")
+    url_template = deepcopy(project_job_client.url_template)
+    url_template.attach_pattern("$job_number/text_log/")
 
     data = {
         "results": [
@@ -150,21 +174,27 @@ def test_job_client_get_text_logs(
     }
 
     # Success
-    requests_mock.get(url_template.render(job_id=1), json=data)
-    assert job_client.get_text_logs(1, 2) == list(reversed(data["results"]))
+    requests_mock.get(url_template.render(
+        **project_job_client.url_kwargs,
+        job_number=1,
+    ), json=data)
+    assert project_job_client.get_text_logs(1, 2) == list(reversed(data["results"]))
 
     # Success w options
     assert (
-        job_client.get_text_logs(
+        project_job_client.get_text_logs(
             1, 2, head=True, log_types=["stdout"], machines=[0], content="2022"
         )
         == data["results"]
     )
 
     # Failed
-    requests_mock.get(url_template.render(job_id=1), status_code=500)
+    requests_mock.get(url_template.render(
+        **project_job_client.url_kwargs,
+        job_number=1,
+    ), status_code=500)
     with pytest.raises(typer.Exit):
-        job_client.get_text_logs(1, 2)
+        project_job_client.get_text_logs(1, 2)
 
 
 @pytest.mark.asyncio
@@ -204,14 +234,16 @@ async def test_job_ws_client(job_ws_client: JobWebSocketClientService):
 
         resp_list = []
         async with job_ws_client.open_connection(
-            job_id=1, log_types=["stdout", "stderr", "vmlog"], machines=[0]
+            job_id="33333333-3333-3333-3333-333333333333",
+            log_types=["stdout", "stderr", "vmlog"],
+            machines=[0]
         ):
             async for resp in job_ws_client:
                 resp_list.append(resp)
 
         get_token_mock.assert_called_once_with(TokenType.ACCESS)
         ws_connect_mock.assert_called_once_with(
-            f"{job_ws_client.url_template.render(1)}?token=ACCESS_TOKEN"
+            f"{job_ws_client.url_template.render(**job_ws_client.url_kwargs, pk='33333333-3333-3333-3333-333333333333')}?token=ACCESS_TOKEN"
         )
         ws_connect_mock.return_value.__aenter__.assert_awaited_once()
         ws_connect_mock.return_value.__aexit__.assert_awaited_once()
@@ -255,7 +287,9 @@ async def test_job_ws_client_errors(job_ws_client: JobWebSocketClientService):
         ws_mock.recv.side_effect = ["not_a_json"]
         with pytest.raises(typer.Exit):
             async with job_ws_client.open_connection(
-                job_id=1, log_types=None, machines=None
+                job_id="33333333-3333-3333-3333-333333333333",
+                log_types=None,
+                machines=None
             ):
                 pass
 
@@ -270,7 +304,9 @@ async def test_job_ws_client_errors(job_ws_client: JobWebSocketClientService):
         ]
         with pytest.raises(typer.Exit):
             async with job_ws_client.open_connection(
-                job_id=1, log_types=None, machines=None
+                job_id="33333333-3333-3333-3333-333333333333",
+                log_types=None,
+                machines=None
             ):
                 pass
 
@@ -285,7 +321,9 @@ async def test_job_ws_client_errors(job_ws_client: JobWebSocketClientService):
         ]
         with pytest.raises(typer.Exit):
             async with job_ws_client.open_connection(
-                job_id=1, log_types=[LogType.STDOUT], machines=None
+                job_id="33333333-3333-3333-3333-333333333333",
+                log_types=[LogType.STDOUT],
+                machines=None
             ):
                 pass
 
@@ -301,7 +339,9 @@ async def test_job_ws_client_errors(job_ws_client: JobWebSocketClientService):
         ]
         with pytest.raises(typer.Exit):
             async with job_ws_client.open_connection(
-                job_id=1, log_types=None, machines=None
+                job_id="33333333-3333-3333-3333-333333333333",
+                log_types=None,
+                machines=None
             ):
                 async for _ in job_ws_client:
                     pass
@@ -310,19 +350,23 @@ async def test_job_ws_client_errors(job_ws_client: JobWebSocketClientService):
 @pytest.mark.usefixtures("patch_auto_token_refresh")
 def test_job_checkpoint_client_list_checkpoints(
     requests_mock: requests_mock.Mocker,
-    job_checkpoint_client: JobCheckpointClientService,
+    job_checkpoint_client: ProjectJobCheckpointClientService,
 ):
-    assert isinstance(job_checkpoint_client, JobCheckpointClientService)
+    assert isinstance(job_checkpoint_client, ProjectJobCheckpointClientService)
 
     # Success
     requests_mock.get(
-        job_checkpoint_client.url_template.render(job_id=1), json=[{"id": 1}]
+        job_checkpoint_client.url_template.render(
+            **job_checkpoint_client.url_kwargs,
+        ), json=[{"id": 1}]
     )
     assert job_checkpoint_client.list_checkpoints() == [{"id": 1}]
 
     # Failed due to HTTP error
     requests_mock.get(
-        job_checkpoint_client.url_template.render(job_id=1), status_code=404
+        job_checkpoint_client.url_template.render(
+            **job_checkpoint_client.url_kwargs,
+        ), status_code=404
     )
     with pytest.raises(typer.Exit):
         job_checkpoint_client.list_checkpoints()
@@ -330,19 +374,21 @@ def test_job_checkpoint_client_list_checkpoints(
 
 @pytest.mark.usefixtures("patch_auto_token_refresh")
 def test_job_artifact_client_list_artifacts(
-    requests_mock: requests_mock.Mocker, job_artifact_client: JobCheckpointClientService
+    requests_mock: requests_mock.Mocker, job_artifact_client: ProjectJobCheckpointClientService
 ):
-    assert isinstance(job_artifact_client, JobArtifactClientService)
+    assert isinstance(job_artifact_client, ProjectJobArtifactClientService)
 
     # Success
     requests_mock.get(
-        job_artifact_client.url_template.render(job_id=1), json=[{"id": 1}]
+        job_artifact_client.url_template.render(
+            **job_artifact_client.url_kwargs,
+        ), json=[{"id": 1}]
     )
     assert job_artifact_client.list_artifacts() == [{"id": 1}]
 
     # Failed due to HTTP error
     requests_mock.get(
-        job_artifact_client.url_template.render(job_id=1), status_code=404
+        job_artifact_client.url_template.render(**job_artifact_client.url_kwargs), status_code=404
     )
     with pytest.raises(typer.Exit):
         job_artifact_client.list_artifacts()
@@ -350,22 +396,24 @@ def test_job_artifact_client_list_artifacts(
 
 @pytest.mark.usefixtures("patch_auto_token_refresh")
 def test_job_artifact_client_get_download_urls(
-    requests_mock: requests_mock.Mocker, job_artifact_client: JobCheckpointClientService
+    requests_mock: requests_mock.Mocker, job_artifact_client: ProjectJobCheckpointClientService
 ):
-    assert isinstance(job_artifact_client, JobArtifactClientService)
+    assert isinstance(job_artifact_client, ProjectJobArtifactClientService)
 
     url_template = job_artifact_client.url_template.copy()
     url_template.attach_pattern("1/download/")
     # Success
     requests_mock.get(
-        url_template.render(job_id=1), json=[{"url": "https://hello.artifact.com"}]
+        url_template.render(
+            **job_artifact_client.url_kwargs,
+        ), json=[{"url": "https://hello.artifact.com"}]
     )
     assert job_artifact_client.get_artifact_download_url(1) == [
         {"url": "https://hello.artifact.com"}
     ]
 
     # Failed due to HTTP error
-    requests_mock.get(url_template.render(job_id=1), status_code=404)
+    requests_mock.get(url_template.render(**job_artifact_client.url_kwargs), status_code=404)
     with pytest.raises(typer.Exit):
         job_artifact_client.get_artifact_download_url(1)
 
@@ -427,3 +475,52 @@ def test_job_template_client_get_job_template_by_name(
     requests_mock.get(job_template_client.url_template.render(), status_code=404)
     with pytest.raises(typer.Exit):
         job_template_client.get_job_template_by_name("some-template")
+
+
+@pytest.mark.usefixtures("patch_auto_token_refresh")
+def test_project_job_client_run_job(
+    requests_mock: requests_mock.Mocker, project_job_client: ProjectJobClientService
+):
+    # Success wo workspace dir
+    requests_mock.post(
+        project_job_client.url_template.render(**project_job_client.url_kwargs),
+        json={"id": 1},
+    )
+    assert project_job_client.run_job({"k": "v"}, None) == {"id": 1}
+
+    # Success w workspace dir
+    with TemporaryDirectory() as dir:
+        ws_dir = Path(dir)
+        with open(ws_dir / "large_file", "wb") as f:
+            f.seek(500 * 1024)  # 500KB
+            f.write(b"0")
+        assert project_job_client.run_job(
+            {
+                "job_setting": {
+                    "workspace": {
+                        "mount_path": "/workspace"
+                }
+            }, "k": "v"}, ws_dir) == {"id": 1}
+
+    # Failed due to large workspace dir exceeding the size limit
+    with TemporaryDirectory() as dir:
+        ws_dir = Path(dir)
+        with open(ws_dir / "large_file", "wb") as f:
+            f.seek(2 * 1024 * 1024 * 1024)  # 2GB
+            f.write(b"0")
+        with pytest.raises(typer.Exit):
+            project_job_client.run_job({"k": "v"}, ws_dir)
+
+    # Failed due to empty workspace dir
+    with TemporaryDirectory() as dir:
+        ws_dir = Path(dir)
+        with pytest.raises(typer.Exit):
+            project_job_client.run_job({"k": "v"}, ws_dir)
+
+    # Failed due to HTTP error
+    requests_mock.post(
+        project_job_client.url_template.render(**project_job_client.url_kwargs),
+        status_code=500,
+    )
+    with pytest.raises(typer.Exit):
+        project_job_client.run_job({"k": "v"}, None)
diff --git a/test/service/client/test_project.py b/test/service/client/test_project.py
index f89e79e..344533c 100644
--- a/test/service/client/test_project.py
+++ b/test/service/client/test_project.py
@@ -3,8 +3,6 @@
 """Test ProjectClient Service"""
 
 from copy import deepcopy
-from pathlib import Path
-from tempfile import TemporaryDirectory
 
 import pytest
 import requests_mock
@@ -15,25 +13,11 @@
 from pfcli.service.client.project import (
     ProjectCredentialClientService,
     ProjectDataClientService,
-    ProjectExperimentClientService,
-    ProjectJobClientService,
-    ProjectVMConfigClientService,
-    ProjectVMQuotaClientService,
+    PFTProjectVMConfigClientService,
+    PFTProjectVMQuotaClientService,
 )
 
 
-@pytest.fixture
-def project_experiment_client(
-    user_project_group_context,
-) -> ProjectExperimentClientService:
-    return build_client(ServiceType.PROJECT_EXPERIMENT)
-
-
-@pytest.fixture
-def project_job_client(user_project_group_context) -> ProjectJobClientService:
-    return build_client(ServiceType.PROJECT_JOB)
-
-
 @pytest.fixture
 def project_credential_client(
     user_project_group_context,
@@ -47,167 +31,15 @@ def project_data_client(user_project_group_context) -> ProjectDataClientService:
 
 
 @pytest.fixture
-def project_vm_quota_client(user_project_group_context) -> ProjectVMQuotaClientService:
-    return build_client(ServiceType.PROJECT_VM_QUOTA)
+def project_vm_quota_client(user_project_group_context) -> PFTProjectVMQuotaClientService:
+    return build_client(ServiceType.PFT_PROJECT_VM_QUOTA)
 
 
 @pytest.fixture
 def project_vm_config_client(
     user_project_group_context,
-) -> ProjectVMConfigClientService:
-    return build_client(ServiceType.PROJECT_VM_CONFIG)
-
-
-@pytest.mark.usefixtures("patch_auto_token_refresh")
-def test_project_experiment_client_list_experiments(
-    requests_mock: requests_mock.Mocker,
-    project_experiment_client: ProjectExperimentClientService,
-):
-    # Success
-    requests_mock.get(
-        project_experiment_client.url_template.render(
-            **project_experiment_client.url_kwargs
-        ),
-        json=[{"id": 0, "name": "exp-0"}, {"id": 1, "name": "exp-1"}],
-    )
-    assert project_experiment_client.list_experiments() == [
-        {"id": 0, "name": "exp-0"},
-        {"id": 1, "name": "exp-1"},
-    ]
-
-    # Failed due to HTTP error
-    requests_mock.get(
-        project_experiment_client.url_template.render(
-            **project_experiment_client.url_kwargs
-        ),
-        status_code=404,
-    )
-    with pytest.raises(typer.Exit):
-        project_experiment_client.list_experiments()
-
-
-@pytest.mark.usefixtures("patch_auto_token_refresh")
-def test_project_experiment_client_get_id_by_name(
-    requests_mock: requests_mock.Mocker,
-    project_experiment_client: ProjectExperimentClientService,
-):
-    # Success
-    requests_mock.get(
-        project_experiment_client.url_template.render(
-            **project_experiment_client.url_kwargs
-        ),
-        json=[{"id": 0, "name": "exp-0"}, {"id": 1, "name": "exp-1"}],
-    )
-    assert project_experiment_client.get_id_by_name("exp-0") == 0
-    assert project_experiment_client.get_id_by_name("exp-1") == 1
-    assert project_experiment_client.get_id_by_name("exp-2") is None
-
-    # Failed due to HTTP error
-    requests_mock.get(
-        project_experiment_client.url_template.render(
-            **project_experiment_client.url_kwargs
-        ),
-        status_code=404,
-    )
-    with pytest.raises(typer.Exit):
-        project_experiment_client.get_id_by_name("exp-3")
-
-
-@pytest.mark.usefixtures("patch_auto_token_refresh")
-def test_project_experiment_client_create_experiment(
-    requests_mock: requests_mock.Mocker,
-    project_experiment_client: ProjectExperimentClientService,
-):
-    # Success
-    requests_mock.post(
-        project_experiment_client.url_template.render(
-            **project_experiment_client.url_kwargs
-        ),
-        json={"id": 0, "name": "exp-0"},
-    )
-    assert project_experiment_client.create_experiment("exp-0") == {
-        "id": 0,
-        "name": "exp-0",
-    }
-
-    # Failed due to HTTP error
-    requests_mock.post(
-        project_experiment_client.url_template.render(
-            **project_experiment_client.url_kwargs
-        ),
-        status_code=400,
-    )
-    with pytest.raises(typer.Exit):
-        project_experiment_client.create_experiment("exp-0")
-
-
-@pytest.mark.usefixtures("patch_auto_token_refresh")
-def test_project_job_client_list_jobs(
-    requests_mock: requests_mock.Mocker, project_job_client: ProjectJobClientService
-):
-    # Success
-    requests_mock.get(
-        project_job_client.url_template.render(**project_job_client.url_kwargs),
-        json={"results": [{"id": 0}, {"id": 1}], "next_cursor": None},
-    )
-    assert project_job_client.list_jobs() == [{"id": 0}, {"id": 1}]
-
-    # Failed due to HTTP error
-    requests_mock.get(
-        project_job_client.url_template.render(**project_job_client.url_kwargs),
-        status_code=404,
-    )
-    with pytest.raises(typer.Exit):
-        project_job_client.list_jobs()
-
-
-@pytest.mark.usefixtures("patch_auto_token_refresh")
-def test_project_job_client_run_job(
-    requests_mock: requests_mock.Mocker, project_job_client: ProjectJobClientService
-):
-    # Success wo workspace dir
-    requests_mock.post(
-        project_job_client.url_template.render(**project_job_client.url_kwargs),
-        json={"id": 1},
-    )
-    assert project_job_client.run_job({"k": "v"}, None) == {"id": 1}
-
-    # Success w workspace dir
-    with TemporaryDirectory() as dir:
-        ws_dir = Path(dir)
-        with open(ws_dir / "large_file", "wb") as f:
-            f.seek(500 * 1024)  # 500KB
-            f.write(b"0")
-        assert project_job_client.run_job(
-            {
-                "job_setting": {
-                    "workspace": {
-                        "mount_path": "/workspace"
-                }
-            }, "k": "v"}, ws_dir) == {"id": 1}
-
-    # Failed due to large workspace dir exceeding the size limit
-    with TemporaryDirectory() as dir:
-        ws_dir = Path(dir)
-        with open(ws_dir / "large_file", "wb") as f:
-            f.seek(2 * 1024 * 1024 * 1024)  # 2GB
-            f.write(b"0")
-        with pytest.raises(typer.Exit):
-            project_job_client.run_job({"k": "v"}, ws_dir)
-
-    # Failed due to empty workspace dir
-    with TemporaryDirectory() as dir:
-        ws_dir = Path(dir)
-        with pytest.raises(typer.Exit):
-            project_job_client.run_job({"k": "v"}, ws_dir)
-
-    # Failed due to HTTP error
-    requests_mock.post(
-        project_job_client.url_template.render(**project_job_client.url_kwargs),
-        status_code=500,
-    )
-    with pytest.raises(typer.Exit):
-        project_job_client.run_job({"k": "v"}, None)
+) -> PFTProjectVMConfigClientService:
+    return build_client(ServiceType.PFT_PROJECT_VM_CONFIG)
 
 
 @pytest.mark.usefixtures("patch_auto_token_refresh")
@@ -309,7 +141,7 @@ def test_project_data_client_create_dataset(
 @pytest.mark.usefixtures("patch_auto_token_refresh")
 def test_project_vm_quota_client_list_vm_quotas(
     requests_mock: requests_mock.Mocker,
-    project_vm_quota_client: ProjectVMQuotaClientService,
+    project_vm_quota_client: PFTProjectVMQuotaClientService,
 ):
     example_data = [
         {
@@ -472,7 +304,7 @@ def test_project_credential_client_service(
 @pytest.mark.usefixtures("patch_auto_token_refresh")
 def test_project_vm_config_client_get_active_vm_count(
     requests_mock: requests_mock.Mocker,
-    project_vm_config_client: ProjectVMConfigClientService,
+    project_vm_config_client: PFTProjectVMConfigClientService,
 ):
     # Success
     url_template = deepcopy(project_vm_config_client.url_template)
diff --git a/test/service/client/test_serve.py b/test/service/client/test_serve.py
deleted file mode 100644
index dbb7add..0000000
--- a/test/service/client/test_serve.py
+++ /dev/null
@@ -1,157 +0,0 @@
-# Copyright (C) 2022 FriendliAI
-
-"""Test ServeClient Service"""
-
-
-from copy import deepcopy
-
-import pytest
-import requests_mock
-import typer
-
-from pfcli.service import ServiceType
-from pfcli.service.client import build_client
-from pfcli.service.client.serve import ServeClientService
-
-
-@pytest.fixture
-def serve_client() -> ServeClientService:
-    return build_client(ServiceType.SERVE)
-
-
-@pytest.mark.usefixtures("patch_auto_token_refresh")
-def test_serve_client_get_serve(
-    requests_mock: requests_mock.Mocker, serve_client: ServeClientService
-):
-    assert isinstance(serve_client, ServeClientService)
-
-    url_template = deepcopy(serve_client.url_template)
-    url_template.attach_pattern("$serve_id/")
-
-    # Success
-    requests_mock.get(
-        url_template.render(serve_id=0),
-        json={
-            "id": 0,
-            "name": "name",
-            "status": "running",
-            "vm": "aws-g4dn.12xlarge",
-            "gpu_type": "t4",
-            "num_gpus": 1,
-            "start": "2022-04-18T05:55:14.365021Z",
-            "endpoint": "http:0.0.0.0:8000/0/v1/completions",
-        },
-    )
-    assert serve_client.get_serve(0) == {
-        "id": 0,
-        "name": "name",
-        "status": "running",
-        "vm": "aws-g4dn.12xlarge",
-        "gpu_type": "t4",
-        "num_gpus": 1,
-        "start": "2022-04-18T05:55:14.365021Z",
-        "endpoint": "http:0.0.0.0:8000/0/v1/completions",
-    }
-
-    requests_mock.get(url_template.render(serve_id=0), status_code=404)
-    with pytest.raises(typer.Exit):
-        serve_client.get_serve(0)
-
-
-@pytest.mark.usefixtures("patch_auto_token_refresh")
-def test_serve_client_create_serve(
-    requests_mock: requests_mock.Mocker, serve_client: ServeClientService
-):
-    assert isinstance(serve_client, ServeClientService)
-
-    # Success
-    requests_mock.post(
-        serve_client.url_template.render(),
-        json={
-            "id": 0,
-            "name": "name",
-            "status": "running",
-            "vm": "aws-g4dn.12xlarge",
-            "gpu_type": "t4",
-            "num_gpus": 1,
-            "start": "2022-04-18T05:55:14.365021Z",
-            "endpoint": "http:0.0.0.0:8000/0/v1/completions",
-        },
-    )
-
-    assert serve_client.create_serve({}) == {
-        "id": 0,
-        "name": "name",
-        "status": "running",
-        "vm": "aws-g4dn.12xlarge",
-        "gpu_type": "t4",
-        "num_gpus": 1,
-        "start": "2022-04-18T05:55:14.365021Z",
-        "endpoint": "http:0.0.0.0:8000/0/v1/completions",
-    }
-
-    requests_mock.post(serve_client.url_template.render(), status_code=404)
-    with pytest.raises(typer.Exit):
-        serve_client.create_serve({})
-
-
-@pytest.mark.usefixtures("patch_auto_token_refresh")
-def test_serve_client_list_serve(
-    requests_mock: requests_mock.Mocker, serve_client: ServeClientService
-):
-    assert isinstance(serve_client, ServeClientService)
-
-    # Success
-    requests_mock.get(
-        serve_client.url_template.render(),
-        json=[
-            {
-                "id": 0,
-                "name": "name",
-                "status": "running",
-                "vm": "aws-g4dn.12xlarge",
-                "gpu_type": "t4",
-                "num_gpus": 1,
-                "start": "2022-04-18T05:55:14.365021Z",
-                "endpoint": "http:0.0.0.0:8000/0/v1/completions",
-            }
-        ],
-    )
-
-    assert serve_client.list_serves() == [
-        {
-            "id": 0,
-            "name": "name",
-            "status": "running",
-            "vm": "aws-g4dn.12xlarge",
-            "gpu_type": "t4",
-            "num_gpus": 1,
-            "start": "2022-04-18T05:55:14.365021Z",
-            "endpoint": "http:0.0.0.0:8000/0/v1/completions",
-        }
-    ]
-
-    requests_mock.get(serve_client.url_template.render(), status_code=404)
-    with pytest.raises(typer.Exit):
-        serve_client.list_serves()
-
-
-@pytest.mark.usefixtures("patch_auto_token_refresh")
-def test_serve_client_delete_serve(
-    requests_mock: requests_mock.Mocker, serve_client: ServeClientService
-):
-    assert isinstance(serve_client, ServeClientService)
-
-    url_template = deepcopy(serve_client.url_template)
-    url_template.attach_pattern("$serve_id/")
-
-    # Success
-    requests_mock.delete(url_template.render(serve_id=0), status_code=204)
-    try:
-        serve_client.delete_serve(0)
-    except typer.Exit:
-        raise pytest.fail("serve client test failed.")
-
-    requests_mock.delete(url_template.render(serve_id=0), status_code=404)
-    with pytest.raises(typer.Exit):
-        serve_client.delete_serve(0)
diff --git a/test/service/client/test_user.py b/test/service/client/test_user.py
index dba5adf..8cf095d 100644
--- a/test/service/client/test_user.py
+++ b/test/service/client/test_user.py
@@ -12,7 +12,7 @@
 from pfcli.service import GroupRole, ProjectRole, ServiceType
 from pfcli.service.client import build_client
 from pfcli.service.client.user import (
-    UserClientService, 
+    UserClientService,
     UserGroupClientService,
     UserMFAService,
     UserSignUpService
@@ -200,9 +200,9 @@ def test_user_group_client_get_group_info(
         url_template.render(**user_group_client.url_kwargs),
         json=[{"id": "00000000-0000-0000-0000-000000000000", "name": "my-group"}],
     )
-    assert user_group_client.get_group_info() == [
-        {"id": "00000000-0000-0000-0000-000000000000", "name": "my-group"}
-    ]
+    assert user_group_client.get_group_info() == {
+        "id": "00000000-0000-0000-0000-000000000000", "name": "my-group"
+    }
 
     # Failed
     requests_mock.get(