qualia-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- qualia_cli/__init__.py +3 -0
- qualia_cli/client.py +29 -0
- qualia_cli/commands/__init__.py +0 -0
- qualia_cli/commands/auth.py +43 -0
- qualia_cli/commands/credits.py +21 -0
- qualia_cli/commands/datasets.py +85 -0
- qualia_cli/commands/finetune.py +200 -0
- qualia_cli/commands/instances.py +42 -0
- qualia_cli/commands/models.py +95 -0
- qualia_cli/commands/projects.py +82 -0
- qualia_cli/config.py +63 -0
- qualia_cli/main.py +76 -0
- qualia_cli/output.py +42 -0
- qualia_cli-0.1.0.dist-info/METADATA +79 -0
- qualia_cli-0.1.0.dist-info/RECORD +17 -0
- qualia_cli-0.1.0.dist-info/WHEEL +4 -0
- qualia_cli-0.1.0.dist-info/entry_points.txt +2 -0
qualia_cli/__init__.py
ADDED
qualia_cli/client.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Shared client factory."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from qualia import AuthenticationError, Qualia
|
|
6
|
+
|
|
7
|
+
from qualia_cli.config import load_config
|
|
8
|
+
from qualia_cli.output import print_error
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def get_client() -> Qualia:
|
|
12
|
+
from qualia_cli.main import _base_url_override, _token_override
|
|
13
|
+
|
|
14
|
+
cfg = load_config()
|
|
15
|
+
kwargs: dict = {}
|
|
16
|
+
if _token_override:
|
|
17
|
+
kwargs["api_key"] = _token_override
|
|
18
|
+
elif cfg.api_key:
|
|
19
|
+
kwargs["api_key"] = cfg.api_key
|
|
20
|
+
if _base_url_override:
|
|
21
|
+
kwargs["base_url"] = _base_url_override
|
|
22
|
+
elif cfg.base_url:
|
|
23
|
+
kwargs["base_url"] = cfg.base_url
|
|
24
|
+
try:
|
|
25
|
+
return Qualia(**kwargs)
|
|
26
|
+
except AuthenticationError as e:
|
|
27
|
+
print_error(str(e))
|
|
28
|
+
print_error("Set QUALIA_API_KEY or run: qualia auth login --token <KEY>")
|
|
29
|
+
raise SystemExit(1) from e
|
|
File without changes
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Auth commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from qualia_cli.config import load_config, save_config
|
|
8
|
+
from qualia_cli.output import print_error
|
|
9
|
+
|
|
10
|
+
app = typer.Typer(help="Authentication.")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@app.command("login")
|
|
14
|
+
def login(
|
|
15
|
+
api_key: str = typer.Option(
|
|
16
|
+
..., prompt="API key", hide_input=True, help="Your Qualia API key"
|
|
17
|
+
),
|
|
18
|
+
base_url: str | None = typer.Option(
|
|
19
|
+
None, help="API base URL (e.g. http://localhost:8000)"
|
|
20
|
+
),
|
|
21
|
+
) -> None:
|
|
22
|
+
"""Save your API key (and optional base URL) for future use."""
|
|
23
|
+
try:
|
|
24
|
+
path = save_config(api_key, base_url)
|
|
25
|
+
typer.echo(f"Config saved to {path}")
|
|
26
|
+
except OSError as e:
|
|
27
|
+
print_error(f"Could not write config: {e}")
|
|
28
|
+
raise SystemExit(1) from e
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@app.command("status")
|
|
32
|
+
def status() -> None:
|
|
33
|
+
"""Check current authentication config."""
|
|
34
|
+
cfg = load_config()
|
|
35
|
+
if cfg.api_key:
|
|
36
|
+
typer.echo(f"API key: {cfg.api_key[:8]}...")
|
|
37
|
+
else:
|
|
38
|
+
typer.echo("Not authenticated. Run: qualia auth login")
|
|
39
|
+
raise SystemExit(1)
|
|
40
|
+
if cfg.base_url:
|
|
41
|
+
typer.echo(f"Base URL: {cfg.base_url}")
|
|
42
|
+
else:
|
|
43
|
+
typer.echo("Base URL: https://api.qualiastudios.dev (default)")
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Credits commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from qualia_cli.client import get_client
|
|
8
|
+
from qualia_cli.output import print_json
|
|
9
|
+
|
|
10
|
+
app = typer.Typer(help="View credit balance.")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@app.callback(invoke_without_command=True)
|
|
14
|
+
def credits(json: bool = typer.Option(False, "--json", help="JSON output")) -> None:
|
|
15
|
+
"""Show your current credit balance."""
|
|
16
|
+
client = get_client()
|
|
17
|
+
balance = client.credits.get()
|
|
18
|
+
if json:
|
|
19
|
+
print_json(balance)
|
|
20
|
+
else:
|
|
21
|
+
typer.echo(f"Credits: {balance.balance}")
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Datasets commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from uuid import UUID
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from qualia_cli.client import get_client
|
|
10
|
+
from qualia_cli.output import print_json, print_table
|
|
11
|
+
|
|
12
|
+
app = typer.Typer(help="Dataset utilities.")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@app.command("list")
|
|
16
|
+
def list_datasets(
|
|
17
|
+
limit: int = typer.Option(20, "--limit", "-n", help="Page size (1-100)"),
|
|
18
|
+
cursor: str = typer.Option(None, "--cursor", help="Pagination cursor"),
|
|
19
|
+
sort: str = typer.Option(
|
|
20
|
+
"newest", "--sort", "-s", help="newest|oldest|updated|name"
|
|
21
|
+
),
|
|
22
|
+
search: str = typer.Option(None, "--search", "-q", help="Search dataset path"),
|
|
23
|
+
subtype: str = typer.Option(
|
|
24
|
+
None, "--dataset-type", help="lerobot_v3 or sarm_progress"
|
|
25
|
+
),
|
|
26
|
+
flow: str = typer.Option(None, "--flow", help="input or output"),
|
|
27
|
+
project_id: str = typer.Option(None, "--project", help="Filter by project ID"),
|
|
28
|
+
job_id: str = typer.Option(None, "--job", help="Filter by job ID"),
|
|
29
|
+
json: bool = typer.Option(False, "--json", help="JSON output"),
|
|
30
|
+
) -> None:
|
|
31
|
+
"""List datasets (paginated)."""
|
|
32
|
+
client = get_client()
|
|
33
|
+
page = client.datasets.list(
|
|
34
|
+
limit=limit,
|
|
35
|
+
cursor=cursor,
|
|
36
|
+
sort=sort,
|
|
37
|
+
search=search,
|
|
38
|
+
subtype=subtype,
|
|
39
|
+
flow=flow,
|
|
40
|
+
project_id=UUID(project_id) if project_id else None,
|
|
41
|
+
job_id=UUID(job_id) if job_id else None,
|
|
42
|
+
)
|
|
43
|
+
if json:
|
|
44
|
+
print_json(page)
|
|
45
|
+
else:
|
|
46
|
+
rows = [
|
|
47
|
+
{
|
|
48
|
+
"id": str(d.artifact_id)[:8],
|
|
49
|
+
"subtype": d.subtype,
|
|
50
|
+
"path": d.path,
|
|
51
|
+
"jobs": len(d.jobs),
|
|
52
|
+
"created": d.created_at.strftime("%Y-%m-%d"),
|
|
53
|
+
}
|
|
54
|
+
for d in page.items
|
|
55
|
+
]
|
|
56
|
+
print_table(
|
|
57
|
+
rows,
|
|
58
|
+
[
|
|
59
|
+
("id", "Dataset ID"),
|
|
60
|
+
("subtype", "Subtype"),
|
|
61
|
+
("path", "Path"),
|
|
62
|
+
("jobs", "Jobs"),
|
|
63
|
+
("created", "Created"),
|
|
64
|
+
],
|
|
65
|
+
)
|
|
66
|
+
if page.page_info.has_next:
|
|
67
|
+
typer.echo(f"\nNext page: --cursor {page.page_info.end_cursor}")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@app.command("get-image-keys")
|
|
71
|
+
def get_image_keys(
|
|
72
|
+
dataset_id: str = typer.Argument(help="HuggingFace dataset ID"),
|
|
73
|
+
json: bool = typer.Option(False, "--json", help="JSON output"),
|
|
74
|
+
) -> None:
|
|
75
|
+
"""Get available image keys for camera mapping."""
|
|
76
|
+
client = get_client()
|
|
77
|
+
result = client.datasets.get_image_keys(dataset_id)
|
|
78
|
+
if json:
|
|
79
|
+
print_json(result)
|
|
80
|
+
else:
|
|
81
|
+
print_table(
|
|
82
|
+
[{"key": k} for k in result.image_keys],
|
|
83
|
+
[("key", "Image Key")],
|
|
84
|
+
title=f"Image keys for {result.dataset_id}",
|
|
85
|
+
)
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""Job commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json as json_mod
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from qualia_cli.client import get_client
|
|
10
|
+
from qualia_cli.output import console, print_error, print_json, print_table
|
|
11
|
+
|
|
12
|
+
app = typer.Typer(help="Manage fine-tuning jobs.")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@app.command("list")
|
|
16
|
+
def list_jobs(
|
|
17
|
+
limit: int = typer.Option(20, "--limit", "-n", help="Page size (1-100)"),
|
|
18
|
+
cursor: str = typer.Option(None, "--cursor", help="Pagination cursor"),
|
|
19
|
+
sort: str = typer.Option("newest", "--sort", "-s", help="newest|oldest|updated"),
|
|
20
|
+
search: str = typer.Option(None, "--search", "-q", help="Search job description"),
|
|
21
|
+
project_id: str = typer.Option(None, "--project", help="Filter by project ID"),
|
|
22
|
+
vla_type: str = typer.Option(None, "--vla-type", help="Filter by VLA type"),
|
|
23
|
+
job_type: str = typer.Option(None, "--job-type", help="vla|reward|vla_w_reward"),
|
|
24
|
+
use_json: bool = typer.Option(False, "--json", help="JSON output"),
|
|
25
|
+
) -> None:
|
|
26
|
+
"""List fine-tuning jobs (paginated)."""
|
|
27
|
+
client = get_client()
|
|
28
|
+
page = client.jobs.list(
|
|
29
|
+
limit=limit,
|
|
30
|
+
cursor=cursor,
|
|
31
|
+
sort=sort,
|
|
32
|
+
project_id=project_id,
|
|
33
|
+
vla_type=vla_type,
|
|
34
|
+
job_type=job_type,
|
|
35
|
+
search=search,
|
|
36
|
+
)
|
|
37
|
+
if use_json:
|
|
38
|
+
print_json(page)
|
|
39
|
+
else:
|
|
40
|
+
rows = [
|
|
41
|
+
{
|
|
42
|
+
"job_id": str(j.job_id)[:8],
|
|
43
|
+
"desc": j.job_desc or "",
|
|
44
|
+
"phase": j.current_phase or "",
|
|
45
|
+
"vla": j.vla_type or "",
|
|
46
|
+
"model": j.model or "",
|
|
47
|
+
"dataset": j.dataset or "",
|
|
48
|
+
"created": j.created_at.strftime("%Y-%m-%d") if j.created_at else "",
|
|
49
|
+
}
|
|
50
|
+
for j in page.items
|
|
51
|
+
]
|
|
52
|
+
print_table(
|
|
53
|
+
rows,
|
|
54
|
+
[
|
|
55
|
+
("job_id", "Job ID"),
|
|
56
|
+
("desc", "Description"),
|
|
57
|
+
("phase", "Phase"),
|
|
58
|
+
("vla", "VLA Type"),
|
|
59
|
+
("model", "Model"),
|
|
60
|
+
("dataset", "Dataset"),
|
|
61
|
+
("created", "Created"),
|
|
62
|
+
],
|
|
63
|
+
)
|
|
64
|
+
if page.page_info.has_next:
|
|
65
|
+
typer.echo(f"\nNext page: --cursor {page.page_info.end_cursor}")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@app.command("launch")
|
|
69
|
+
def launch(
|
|
70
|
+
project_id: str = typer.Option(..., help="Project ID"),
|
|
71
|
+
vla_type: str = typer.Option(
|
|
72
|
+
..., help="VLA type (act, smolvla, pi0, pi05, gr00t_n1_5)"
|
|
73
|
+
),
|
|
74
|
+
dataset_id: str = typer.Option(..., help="HuggingFace dataset ID"),
|
|
75
|
+
hours: float = typer.Option(..., help="Training hours"),
|
|
76
|
+
camera_mappings: str = typer.Option(
|
|
77
|
+
...,
|
|
78
|
+
help='Camera mappings as JSON, e.g. \'{"cam_1": "observation.images.top"}\'',
|
|
79
|
+
),
|
|
80
|
+
model_id: str | None = typer.Option(
|
|
81
|
+
None, help="HuggingFace model ID (required for smolvla, pi0, pi05)"
|
|
82
|
+
),
|
|
83
|
+
instance_type: str | None = typer.Option(None, help="GPU instance type"),
|
|
84
|
+
region: str | None = typer.Option(None, help="Cloud region"),
|
|
85
|
+
batch_size: int = typer.Option(32, help="Training batch size"),
|
|
86
|
+
name: str | None = typer.Option(None, help="Job name"),
|
|
87
|
+
hyper_spec: str | None = typer.Option(None, help="Custom hyperparams as JSON"),
|
|
88
|
+
use_json: bool = typer.Option(False, "--json", help="JSON output"),
|
|
89
|
+
) -> None:
|
|
90
|
+
"""Launch a new fine-tuning job."""
|
|
91
|
+
try:
|
|
92
|
+
cam_map = json_mod.loads(camera_mappings)
|
|
93
|
+
except json_mod.JSONDecodeError as e:
|
|
94
|
+
print_error("Invalid JSON for --camera-mappings")
|
|
95
|
+
raise SystemExit(1) from e
|
|
96
|
+
|
|
97
|
+
kwargs: dict = {
|
|
98
|
+
"project_id": project_id,
|
|
99
|
+
"vla_type": vla_type,
|
|
100
|
+
"dataset_id": dataset_id,
|
|
101
|
+
"hours": hours,
|
|
102
|
+
"camera_mappings": cam_map,
|
|
103
|
+
"batch_size": batch_size,
|
|
104
|
+
}
|
|
105
|
+
if model_id:
|
|
106
|
+
kwargs["model_id"] = model_id
|
|
107
|
+
if instance_type:
|
|
108
|
+
kwargs["instance_type"] = instance_type
|
|
109
|
+
if region:
|
|
110
|
+
kwargs["region"] = region
|
|
111
|
+
if name:
|
|
112
|
+
kwargs["name"] = name
|
|
113
|
+
if hyper_spec:
|
|
114
|
+
try:
|
|
115
|
+
kwargs["vla_hyper_spec"] = json_mod.loads(hyper_spec)
|
|
116
|
+
except json_mod.JSONDecodeError as e:
|
|
117
|
+
print_error("Invalid JSON for --hyper-spec")
|
|
118
|
+
raise SystemExit(1) from e
|
|
119
|
+
|
|
120
|
+
client = get_client()
|
|
121
|
+
job = client.finetune.create(**kwargs)
|
|
122
|
+
if use_json:
|
|
123
|
+
print_json(job)
|
|
124
|
+
else:
|
|
125
|
+
typer.echo(f"Job launched: {job.job_id} (status: {job.status})")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@app.command("get")
|
|
129
|
+
def get_status(
|
|
130
|
+
job_id: str = typer.Argument(help="Job ID"),
|
|
131
|
+
use_json: bool = typer.Option(False, "--json", help="JSON output"),
|
|
132
|
+
) -> None:
|
|
133
|
+
"""Get job status."""
|
|
134
|
+
client = get_client()
|
|
135
|
+
status = client.finetune.get(job_id)
|
|
136
|
+
if use_json:
|
|
137
|
+
print_json(status)
|
|
138
|
+
else:
|
|
139
|
+
console.print(f"[bold]Job:[/bold] {status.job_id}")
|
|
140
|
+
console.print(f"[bold]Status:[/bold] {status.status}")
|
|
141
|
+
console.print(f"[bold]Phase:[/bold] {status.current_phase}")
|
|
142
|
+
if status.instance_type:
|
|
143
|
+
console.print(f"[bold]Instance:[/bold] {status.instance_type}")
|
|
144
|
+
if status.region:
|
|
145
|
+
console.print(f"[bold]Region:[/bold] {status.region}")
|
|
146
|
+
if status.phases:
|
|
147
|
+
console.print()
|
|
148
|
+
rows = [
|
|
149
|
+
{
|
|
150
|
+
"name": p.name,
|
|
151
|
+
"status": p.status,
|
|
152
|
+
"started": str(p.started_at or "-"),
|
|
153
|
+
"completed": str(p.completed_at or "-"),
|
|
154
|
+
"error": p.error or "",
|
|
155
|
+
}
|
|
156
|
+
for p in status.phases
|
|
157
|
+
]
|
|
158
|
+
print_table(
|
|
159
|
+
rows,
|
|
160
|
+
[
|
|
161
|
+
("name", "Phase"),
|
|
162
|
+
("status", "Status"),
|
|
163
|
+
("started", "Started"),
|
|
164
|
+
("completed", "Completed"),
|
|
165
|
+
("error", "Error"),
|
|
166
|
+
],
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@app.command("cancel")
|
|
171
|
+
def cancel(
|
|
172
|
+
job_id: str = typer.Argument(help="Job ID to cancel"),
|
|
173
|
+
use_json: bool = typer.Option(False, "--json", help="JSON output"),
|
|
174
|
+
) -> None:
|
|
175
|
+
"""Cancel a job."""
|
|
176
|
+
client = get_client()
|
|
177
|
+
result = client.finetune.cancel(job_id)
|
|
178
|
+
if use_json:
|
|
179
|
+
print_json(result)
|
|
180
|
+
else:
|
|
181
|
+
if result.cancelled:
|
|
182
|
+
typer.echo(f"Cancelled job {result.job_id}")
|
|
183
|
+
else:
|
|
184
|
+
typer.echo(f"Could not cancel job {result.job_id}: {result.message}")
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@app.command("hyperparams")
|
|
188
|
+
def hyperparams(
|
|
189
|
+
vla_type: str = typer.Argument(help="VLA type"),
|
|
190
|
+
model_id: str | None = typer.Option(
|
|
191
|
+
None, help="Model ID for type-specific defaults"
|
|
192
|
+
),
|
|
193
|
+
) -> None:
|
|
194
|
+
"""Show default hyperparameters for a VLA type."""
|
|
195
|
+
client = get_client()
|
|
196
|
+
defaults = client.finetune.get_hyperparams_defaults(
|
|
197
|
+
vla_type=vla_type,
|
|
198
|
+
model_id=model_id,
|
|
199
|
+
)
|
|
200
|
+
print_json(defaults)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Instances commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from qualia_cli.client import get_client
|
|
8
|
+
from qualia_cli.output import print_json, print_table
|
|
9
|
+
|
|
10
|
+
app = typer.Typer(help="GPU instance information.")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@app.command("list")
|
|
14
|
+
def list_instances(
|
|
15
|
+
json: bool = typer.Option(False, "--json", help="JSON output"),
|
|
16
|
+
) -> None:
|
|
17
|
+
"""List available GPU instance types."""
|
|
18
|
+
client = get_client()
|
|
19
|
+
instances = client.instances.list()
|
|
20
|
+
if json:
|
|
21
|
+
print_json(instances)
|
|
22
|
+
else:
|
|
23
|
+
rows = [
|
|
24
|
+
{
|
|
25
|
+
"id": i.id,
|
|
26
|
+
"name": i.name,
|
|
27
|
+
"gpu": i.gpu_description,
|
|
28
|
+
"cost": str(i.credits_per_hour),
|
|
29
|
+
"regions": str(i.region_count),
|
|
30
|
+
}
|
|
31
|
+
for i in instances
|
|
32
|
+
]
|
|
33
|
+
print_table(
|
|
34
|
+
rows,
|
|
35
|
+
[
|
|
36
|
+
("id", "ID"),
|
|
37
|
+
("name", "Name"),
|
|
38
|
+
("gpu", "GPU"),
|
|
39
|
+
("cost", "Credits/hr"),
|
|
40
|
+
("regions", "Regions"),
|
|
41
|
+
],
|
|
42
|
+
)
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Models commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from uuid import UUID
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from qualia_cli.client import get_client
|
|
10
|
+
from qualia_cli.output import print_json, print_table
|
|
11
|
+
|
|
12
|
+
app = typer.Typer(help="VLA model information.")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@app.command("types")
|
|
16
|
+
def list_types(
|
|
17
|
+
json: bool = typer.Option(False, "--json", help="JSON output"),
|
|
18
|
+
) -> None:
|
|
19
|
+
"""List available VLA model types."""
|
|
20
|
+
client = get_client()
|
|
21
|
+
models = client.models.types()
|
|
22
|
+
if json:
|
|
23
|
+
print_json(models)
|
|
24
|
+
else:
|
|
25
|
+
rows = [
|
|
26
|
+
{
|
|
27
|
+
"id": m.id,
|
|
28
|
+
"name": m.name,
|
|
29
|
+
"base": m.base_model_id or "-",
|
|
30
|
+
"cameras": ", ".join(m.camera_slots),
|
|
31
|
+
"custom": "yes" if m.supports_custom_model else "no",
|
|
32
|
+
}
|
|
33
|
+
for m in models
|
|
34
|
+
]
|
|
35
|
+
print_table(
|
|
36
|
+
rows,
|
|
37
|
+
[
|
|
38
|
+
("id", "ID"),
|
|
39
|
+
("name", "Name"),
|
|
40
|
+
("base", "Base Model"),
|
|
41
|
+
("cameras", "Camera Slots"),
|
|
42
|
+
("custom", "Custom Model"),
|
|
43
|
+
],
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@app.command("list")
|
|
48
|
+
def list_models(
|
|
49
|
+
limit: int = typer.Option(20, "--limit", "-n", help="Page size (1-100)"),
|
|
50
|
+
cursor: str = typer.Option(None, "--cursor", help="Pagination cursor"),
|
|
51
|
+
sort: str = typer.Option(
|
|
52
|
+
"newest", "--sort", "-s", help="newest|oldest|updated|name"
|
|
53
|
+
),
|
|
54
|
+
search: str = typer.Option(None, "--search", "-q", help="Search model path"),
|
|
55
|
+
subtype: str = typer.Option(None, "--model-type", help="vla_model or reward_model"),
|
|
56
|
+
project_id: str = typer.Option(None, "--project", help="Filter by project ID"),
|
|
57
|
+
job_id: str = typer.Option(None, "--job", help="Filter by job ID"),
|
|
58
|
+
json: bool = typer.Option(False, "--json", help="JSON output"),
|
|
59
|
+
) -> None:
|
|
60
|
+
"""List trained models (paginated)."""
|
|
61
|
+
client = get_client()
|
|
62
|
+
page = client.models.list_trained(
|
|
63
|
+
limit=limit,
|
|
64
|
+
cursor=cursor,
|
|
65
|
+
sort=sort,
|
|
66
|
+
search=search,
|
|
67
|
+
subtype=subtype,
|
|
68
|
+
project_id=UUID(project_id) if project_id else None,
|
|
69
|
+
job_id=UUID(job_id) if job_id else None,
|
|
70
|
+
)
|
|
71
|
+
if json:
|
|
72
|
+
print_json(page)
|
|
73
|
+
else:
|
|
74
|
+
rows = [
|
|
75
|
+
{
|
|
76
|
+
"id": str(m.artifact_id)[:8],
|
|
77
|
+
"subtype": m.subtype,
|
|
78
|
+
"path": m.path,
|
|
79
|
+
"jobs": len(m.jobs),
|
|
80
|
+
"created": m.created_at.strftime("%Y-%m-%d"),
|
|
81
|
+
}
|
|
82
|
+
for m in page.items
|
|
83
|
+
]
|
|
84
|
+
print_table(
|
|
85
|
+
rows,
|
|
86
|
+
[
|
|
87
|
+
("id", "Model ID"),
|
|
88
|
+
("subtype", "Subtype"),
|
|
89
|
+
("path", "Path"),
|
|
90
|
+
("jobs", "Jobs"),
|
|
91
|
+
("created", "Created"),
|
|
92
|
+
],
|
|
93
|
+
)
|
|
94
|
+
if page.page_info.has_next:
|
|
95
|
+
typer.echo(f"\nNext page: --cursor {page.page_info.end_cursor}")
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Projects commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from qualia_cli.client import get_client
|
|
8
|
+
from qualia_cli.output import print_json, print_table
|
|
9
|
+
|
|
10
|
+
app = typer.Typer(help="Manage projects.")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@app.command("list")
|
|
14
|
+
def list_projects(
|
|
15
|
+
limit: int = typer.Option(20, "--limit", "-n", help="Page size (1-100)"),
|
|
16
|
+
cursor: str = typer.Option(None, "--cursor", help="Pagination cursor"),
|
|
17
|
+
sort: str = typer.Option(
|
|
18
|
+
"newest", "--sort", "-s", help="newest|oldest|updated|name"
|
|
19
|
+
),
|
|
20
|
+
search: str = typer.Option(None, "--search", "-q", help="Search project name"),
|
|
21
|
+
json: bool = typer.Option(False, "--json", help="JSON output"),
|
|
22
|
+
) -> None:
|
|
23
|
+
"""List projects (paginated)."""
|
|
24
|
+
client = get_client()
|
|
25
|
+
page = client.projects.list(
|
|
26
|
+
limit=limit,
|
|
27
|
+
cursor=cursor,
|
|
28
|
+
sort=sort,
|
|
29
|
+
search=search,
|
|
30
|
+
)
|
|
31
|
+
if json:
|
|
32
|
+
print_json(page)
|
|
33
|
+
else:
|
|
34
|
+
rows = [
|
|
35
|
+
{
|
|
36
|
+
"id": str(p.project_id)[:8],
|
|
37
|
+
"name": p.name,
|
|
38
|
+
"desc": p.description or "",
|
|
39
|
+
"created": p.created_at.strftime("%Y-%m-%d"),
|
|
40
|
+
}
|
|
41
|
+
for p in page.items
|
|
42
|
+
]
|
|
43
|
+
print_table(
|
|
44
|
+
rows,
|
|
45
|
+
[
|
|
46
|
+
("id", "Project ID"),
|
|
47
|
+
("name", "Name"),
|
|
48
|
+
("desc", "Description"),
|
|
49
|
+
("created", "Created"),
|
|
50
|
+
],
|
|
51
|
+
)
|
|
52
|
+
if page.page_info.has_next:
|
|
53
|
+
typer.echo(f"\nNext page: --cursor {page.page_info.end_cursor}")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@app.command("create")
|
|
57
|
+
def create_project(
|
|
58
|
+
name: str = typer.Argument(help="Project name"),
|
|
59
|
+
description: str = typer.Option(None, "--description", "-d", help="Description"),
|
|
60
|
+
json: bool = typer.Option(False, "--json", help="JSON output"),
|
|
61
|
+
) -> None:
|
|
62
|
+
"""Create a new project."""
|
|
63
|
+
client = get_client()
|
|
64
|
+
result = client.projects.create(name=name, description=description)
|
|
65
|
+
if json:
|
|
66
|
+
print_json(result)
|
|
67
|
+
else:
|
|
68
|
+
typer.echo(f"Created project {result.project_id}")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@app.command("delete")
|
|
72
|
+
def delete_project(
|
|
73
|
+
project_id: str = typer.Argument(help="Project ID to delete"),
|
|
74
|
+
json: bool = typer.Option(False, "--json", help="JSON output"),
|
|
75
|
+
) -> None:
|
|
76
|
+
"""Delete a project."""
|
|
77
|
+
client = get_client()
|
|
78
|
+
result = client.projects.delete(project_id)
|
|
79
|
+
if json:
|
|
80
|
+
print_json(result)
|
|
81
|
+
else:
|
|
82
|
+
typer.echo(f"Deleted project {result.project_id}")
|
qualia_cli/config.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Config file loading and writing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
|
|
11
|
+
CONFIG_DIR = Path(click.get_app_dir("qualia"))
|
|
12
|
+
CONFIG_FILE = CONFIG_DIR / "config.toml"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class Config:
|
|
17
|
+
api_key: str | None = None
|
|
18
|
+
base_url: str | None = None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _parse_toml_value(line: str, key: str) -> str | None:
|
|
22
|
+
"""Extract value for a key from a simple TOML line."""
|
|
23
|
+
stripped = line.strip()
|
|
24
|
+
if stripped.startswith(key):
|
|
25
|
+
_, _, val = stripped.partition("=")
|
|
26
|
+
return val.strip().strip('"').strip("'")
|
|
27
|
+
return None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def load_config() -> Config:
|
|
31
|
+
"""Load config from env vars, falling back to config file."""
|
|
32
|
+
cfg = Config()
|
|
33
|
+
|
|
34
|
+
# env vars take precedence
|
|
35
|
+
cfg.api_key = os.environ.get("QUALIA_API_KEY")
|
|
36
|
+
cfg.base_url = os.environ.get("QUALIA_BASE_URL")
|
|
37
|
+
|
|
38
|
+
if (cfg.api_key and cfg.base_url) or not CONFIG_FILE.exists():
|
|
39
|
+
return cfg
|
|
40
|
+
|
|
41
|
+
for line in CONFIG_FILE.read_text().splitlines():
|
|
42
|
+
if not cfg.api_key:
|
|
43
|
+
val = _parse_toml_value(line, "api_key")
|
|
44
|
+
if val:
|
|
45
|
+
cfg.api_key = val
|
|
46
|
+
if not cfg.base_url:
|
|
47
|
+
val = _parse_toml_value(line, "base_url")
|
|
48
|
+
if val:
|
|
49
|
+
cfg.base_url = val
|
|
50
|
+
|
|
51
|
+
return cfg
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def save_config(api_key: str, base_url: str | None = None) -> Path:
|
|
55
|
+
"""Write config to file. Returns the path written."""
|
|
56
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
57
|
+
lines = [f'api_key = "{api_key}"']
|
|
58
|
+
if base_url:
|
|
59
|
+
lines.append(f'base_url = "{base_url}"')
|
|
60
|
+
CONFIG_FILE.write_text("\n".join(lines) + "\n")
|
|
61
|
+
if os.name != "nt":
|
|
62
|
+
CONFIG_FILE.chmod(0o600)
|
|
63
|
+
return CONFIG_FILE
|
qualia_cli/main.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Qualia CLI - terminal interface for the Qualia VLA fine-tuning platform."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from qualia import QualiaAPIError, QualiaError
|
|
7
|
+
|
|
8
|
+
from qualia_cli.commands import (
|
|
9
|
+
auth,
|
|
10
|
+
credits,
|
|
11
|
+
datasets,
|
|
12
|
+
finetune,
|
|
13
|
+
instances,
|
|
14
|
+
models,
|
|
15
|
+
projects,
|
|
16
|
+
)
|
|
17
|
+
from qualia_cli.output import print_error
|
|
18
|
+
|
|
19
|
+
app = typer.Typer(
|
|
20
|
+
name="qualia",
|
|
21
|
+
help="Qualia CLI - fine-tune Vision-Language-Action models.",
|
|
22
|
+
no_args_is_help=True,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
app.add_typer(auth.app, name="auth")
|
|
26
|
+
app.add_typer(credits.app, name="credit")
|
|
27
|
+
app.add_typer(datasets.app, name="dataset")
|
|
28
|
+
app.add_typer(finetune.app, name="job")
|
|
29
|
+
app.add_typer(instances.app, name="instance")
|
|
30
|
+
app.add_typer(models.app, name="model")
|
|
31
|
+
app.add_typer(projects.app, name="project")
|
|
32
|
+
|
|
33
|
+
# Global overrides set by the callback, consumed by get_client()
|
|
34
|
+
_token_override: str | None = None
|
|
35
|
+
_base_url_override: str | None = None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@app.callback()
|
|
39
|
+
def main(
|
|
40
|
+
token: str | None = typer.Option(
|
|
41
|
+
None, "--token", envvar="QUALIA_API_KEY", help="API key (overrides config)"
|
|
42
|
+
),
|
|
43
|
+
base_url: str | None = typer.Option(
|
|
44
|
+
None, "--base-url", envvar="QUALIA_BASE_URL", help="API base URL"
|
|
45
|
+
),
|
|
46
|
+
) -> None:
|
|
47
|
+
"""Qualia CLI - fine-tune Vision-Language-Action models."""
|
|
48
|
+
global _token_override, _base_url_override
|
|
49
|
+
_token_override = token
|
|
50
|
+
_base_url_override = base_url
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@app.command()
|
|
54
|
+
def version() -> None:
|
|
55
|
+
"""Show CLI and SDK versions."""
|
|
56
|
+
from qualia import __version__ as sdk_version
|
|
57
|
+
|
|
58
|
+
from qualia_cli import __version__ as cli_version
|
|
59
|
+
|
|
60
|
+
typer.echo(f"qualia-cli {cli_version}")
|
|
61
|
+
typer.echo(f"qualia-sdk {sdk_version}")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def cli() -> None:
|
|
65
|
+
try:
|
|
66
|
+
app()
|
|
67
|
+
except QualiaAPIError as e:
|
|
68
|
+
print_error(str(e))
|
|
69
|
+
raise SystemExit(1) from e
|
|
70
|
+
except QualiaError as e:
|
|
71
|
+
print_error(e.message)
|
|
72
|
+
raise SystemExit(1) from e
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
if __name__ == "__main__":
|
|
76
|
+
cli()
|
qualia_cli/output.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Output formatting helpers for table and JSON display."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
from rich.table import Table
|
|
10
|
+
|
|
11
|
+
console = Console()
|
|
12
|
+
err_console = Console(stderr=True)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def print_json(data: Any) -> None:
|
|
16
|
+
"""Print data as formatted JSON."""
|
|
17
|
+
if hasattr(data, "model_dump"):
|
|
18
|
+
data = data.model_dump(mode="json")
|
|
19
|
+
elif isinstance(data, list) and data and hasattr(data[0], "model_dump"):
|
|
20
|
+
data = [d.model_dump(mode="json") for d in data]
|
|
21
|
+
console.print_json(json.dumps(data, default=str))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def print_table(
|
|
25
|
+
rows: list[dict[str, Any]],
|
|
26
|
+
columns: list[tuple[str, str]],
|
|
27
|
+
title: str | None = None,
|
|
28
|
+
) -> None:
|
|
29
|
+
"""Print rows as a rich table.
|
|
30
|
+
|
|
31
|
+
columns: list of (key, header_label) tuples.
|
|
32
|
+
"""
|
|
33
|
+
table = Table(title=title)
|
|
34
|
+
for _, header in columns:
|
|
35
|
+
table.add_column(header)
|
|
36
|
+
for row in rows:
|
|
37
|
+
table.add_row(*(str(row.get(k, "")) for k, _ in columns))
|
|
38
|
+
console.print(table)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def print_error(msg: str) -> None:
|
|
42
|
+
err_console.print(f"[red]Error:[/red] {msg}")
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: qualia-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: CLI for the Qualia Studios VLA fine-tuning platform
|
|
5
|
+
Project-URL: Homepage, https://qualiastudios.dev
|
|
6
|
+
Project-URL: Documentation, https://docs.qualiastudios.dev
|
|
7
|
+
Author: Qualia Studios
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
Keywords: cli,fine-tuning,qualia,robotics,vla
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Typing :: Typed
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Requires-Dist: qualia-sdk>=0.2.0
|
|
20
|
+
Requires-Dist: rich>=13.0.0
|
|
21
|
+
Requires-Dist: typer>=0.15.0
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# Qualia CLI
|
|
25
|
+
|
|
26
|
+
Terminal interface for the [Qualia](https://qualiastudios.dev) VLA fine-tuning platform.
|
|
27
|
+
|
|
28
|
+
## Install
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
pip install qualia-cli
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Or with [uv](https://docs.astral.sh/uv/):
|
|
35
|
+
|
|
36
|
+
```sh
|
|
37
|
+
uv tool install qualia-cli
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Quick start
|
|
41
|
+
|
|
42
|
+
```sh
|
|
43
|
+
# Authenticate (pick one)
|
|
44
|
+
qualia auth login # interactive, saves to config
|
|
45
|
+
export QUALIA_API_KEY="your-api-key" # env var
|
|
46
|
+
qualia --token <KEY> <command> # inline, for CI/scripts
|
|
47
|
+
|
|
48
|
+
# List available models
|
|
49
|
+
qualia models list
|
|
50
|
+
|
|
51
|
+
# List GPU instances
|
|
52
|
+
qualia instances list
|
|
53
|
+
|
|
54
|
+
# Create a project
|
|
55
|
+
qualia projects create "My Robot"
|
|
56
|
+
|
|
57
|
+
# Start a fine-tuning job
|
|
58
|
+
qualia finetune create \
|
|
59
|
+
--project-id <id> \
|
|
60
|
+
--vla-type smolvla \
|
|
61
|
+
--model-id lerobot/smolvla_base \
|
|
62
|
+
--dataset-id lerobot/pusht \
|
|
63
|
+
--dataset-last-modified "2025-01-15T10:00:00Z" \
|
|
64
|
+
--model-last-modified "2025-01-15T10:00:00Z" \
|
|
65
|
+
--hours 2.0 \
|
|
66
|
+
--camera-mappings '{"cam_1": "observation.images.top"}'
|
|
67
|
+
|
|
68
|
+
# Check job status
|
|
69
|
+
qualia finetune get <job-id>
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## JSON output
|
|
73
|
+
|
|
74
|
+
All commands support `--json` for machine-readable output:
|
|
75
|
+
|
|
76
|
+
```sh
|
|
77
|
+
qualia projects list --json
|
|
78
|
+
qualia credits --json
|
|
79
|
+
```
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
qualia_cli/__init__.py,sha256=xpooCLfcAl2BTsYxuAJzb8lBfskcCi-r7b-HLuGEKKc,102
|
|
2
|
+
qualia_cli/client.py,sha256=yrb2xrfn2OabbBaJYVmTknKPDcvHHPckHko78KcglhI,836
|
|
3
|
+
qualia_cli/config.py,sha256=QD4IZ1tsYbfrRkRUc9BNrfnXHrXGVkNdVNo0JtEEtIw,1725
|
|
4
|
+
qualia_cli/main.py,sha256=mwEncy2S1UomSSyz5SwY0RY4mVTxnEkxu8hpHGrViv0,1920
|
|
5
|
+
qualia_cli/output.py,sha256=bazOtQRA6p0124GfAglha0d-wOFwJsLAHBl2aqr9wiY,1110
|
|
6
|
+
qualia_cli/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
qualia_cli/commands/auth.py,sha256=551vEGWPAexEE1WFdx6VIa1SeUmh32X6os395KiCBF4,1209
|
|
8
|
+
qualia_cli/commands/credits.py,sha256=g7SuguSWwai3kTiFh3CMbkfBWesHOl0F9uYtu3VCyQg,541
|
|
9
|
+
qualia_cli/commands/datasets.py,sha256=mlWOxNe8mvBfnrKvyXKD-70-8UlrxEzuEXqqBJGjQhE,2671
|
|
10
|
+
qualia_cli/commands/finetune.py,sha256=xlwkS_ZkAtzclRolyQjxW8aSejBPh8bUt5CQJXIktXo,6710
|
|
11
|
+
qualia_cli/commands/instances.py,sha256=4ozIzvR-MQUBjceZQMUUUtjBjY83nqRJ7vUSkVluRk0,1050
|
|
12
|
+
qualia_cli/commands/models.py,sha256=fl_eKsXSqEhecr1Nh0LC-lGt0JRsuvzhzpcSzx72nPc,2865
|
|
13
|
+
qualia_cli/commands/projects.py,sha256=CmI1tHOZvt84rB4w_SyygoDkse7U3hTzP1ZlBl2Rtw0,2434
|
|
14
|
+
qualia_cli-0.1.0.dist-info/METADATA,sha256=BTpdMBqXTbCtMMJF9hi90HxThgMXLE2q3GwJoRHL3jE,1977
|
|
15
|
+
qualia_cli-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
16
|
+
qualia_cli-0.1.0.dist-info/entry_points.txt,sha256=hjLKGQ8xf2rlxWfPuZFyCvVk8PSDtDNxpXHyz6woUM0,47
|
|
17
|
+
qualia_cli-0.1.0.dist-info/RECORD,,
|