nextron-cloud 0.1.0rc1__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.
- nextron_cloud/__init__.py +10 -0
- nextron_cloud/_client.py +129 -0
- nextron_cloud/_version.py +1 -0
- nextron_cloud/cli.py +272 -0
- nextron_cloud/data.py +227 -0
- nextron_cloud/datasets.py +79 -0
- nextron_cloud/experiments.py +75 -0
- nextron_cloud/guide/SKILL.md +115 -0
- nextron_cloud/guide/references/api.md +93 -0
- nextron_cloud/guide/references/checkpoints.md +65 -0
- nextron_cloud/guide/references/data.md +124 -0
- nextron_cloud/guide/references/monitoring.md +114 -0
- nextron_cloud/guide/references/training.md +129 -0
- nextron_cloud/jobs.py +196 -0
- nextron_cloud/py.typed +0 -0
- nextron_cloud/types.py +219 -0
- nextron_cloud-0.1.0rc1.dist-info/METADATA +102 -0
- nextron_cloud-0.1.0rc1.dist-info/RECORD +21 -0
- nextron_cloud-0.1.0rc1.dist-info/WHEEL +4 -0
- nextron_cloud-0.1.0rc1.dist-info/entry_points.txt +2 -0
- nextron_cloud-0.1.0rc1.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Python client for the NexTron Cloud fine-tuning API."""
|
|
2
|
+
from ._client import APIError, NexTron, NexTronError
|
|
3
|
+
from ._version import __version__
|
|
4
|
+
from .data import CheckResult, check_file, to_messages, write_jsonl
|
|
5
|
+
from .types import (Account, Billing, Checkpoint, CheckpointFile, Dataset, Estimate, Event, Job, Layout, LogLine, MetricPoint,
|
|
6
|
+
ModelInfo, ModelLayout, ModelMethod, Org, Progress, TrainingParams, Wandb)
|
|
7
|
+
|
|
8
|
+
__all__ = ["NexTron", "NexTronError", "APIError", "Account", "Org", "Layout", "Dataset", "Job", "Estimate",
|
|
9
|
+
"MetricPoint", "LogLine", "Event", "Progress", "Billing", "ModelInfo", "ModelMethod", "ModelLayout", "Checkpoint", "CheckpointFile", "TrainingParams", "Wandb",
|
|
10
|
+
"CheckResult", "check_file", "to_messages", "write_jsonl", "__version__"]
|
nextron_cloud/_client.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import time
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from typing import Any, TypeVar
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from ._version import __version__
|
|
11
|
+
from .types import Account, Layout, ModelInfo
|
|
12
|
+
|
|
13
|
+
DEFAULT_BASE_URL = "https://nextron.physionlabs.ai"
|
|
14
|
+
RETRY_STATUS = (429, 502, 503, 504)
|
|
15
|
+
T = TypeVar("T")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class NexTronError(Exception):
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class APIError(NexTronError):
|
|
23
|
+
"""A 4xx or 5xx answer. `code` is the server's short error name when it gives one."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, status_code: int, message: str, code: str | None = None, body: Any = None):
|
|
26
|
+
super().__init__(f"{status_code}: {message}")
|
|
27
|
+
self.status_code = status_code
|
|
28
|
+
self.message = message
|
|
29
|
+
self.code = code
|
|
30
|
+
self.body = body
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _api_error(r: httpx.Response) -> APIError:
|
|
34
|
+
try:
|
|
35
|
+
body = r.json()
|
|
36
|
+
except ValueError:
|
|
37
|
+
return APIError(r.status_code, r.text or r.reason_phrase)
|
|
38
|
+
if not isinstance(body, dict):
|
|
39
|
+
return APIError(r.status_code, str(body), body=body)
|
|
40
|
+
detail = body.get("detail")
|
|
41
|
+
code = body.get("error")
|
|
42
|
+
message = detail if isinstance(detail, str) else str(detail or code or r.reason_phrase)
|
|
43
|
+
return APIError(r.status_code, message, code, body)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class NexTron:
|
|
47
|
+
"""Client for the NexTron Cloud API. The key comes from `api_key` or NEXTRON_API_KEY."""
|
|
48
|
+
|
|
49
|
+
def __init__(self, api_key: str | None = None, *, base_url: str | None = None, org: str | None = None,
|
|
50
|
+
timeout: float = 60.0, max_retries: int = 3):
|
|
51
|
+
api_key = api_key or os.environ.get("NEXTRON_API_KEY")
|
|
52
|
+
if not api_key:
|
|
53
|
+
raise NexTronError("no API key: pass api_key or set NEXTRON_API_KEY")
|
|
54
|
+
headers = {"Authorization": f"Bearer {api_key}", "User-Agent": f"nextron-cloud-python/{__version__}"}
|
|
55
|
+
if org:
|
|
56
|
+
headers["X-Nextron-Org"] = org
|
|
57
|
+
base_url = base_url or os.environ.get("NEXTRON_BASE_URL") or DEFAULT_BASE_URL
|
|
58
|
+
url = httpx.URL(base_url)
|
|
59
|
+
if url.scheme != "https" and url.host not in ("localhost", "127.0.0.1", "::1"):
|
|
60
|
+
raise NexTronError(f"refusing to send the API key over {url.scheme}://; use https")
|
|
61
|
+
self._http = httpx.Client(base_url=base_url, headers=headers, timeout=timeout)
|
|
62
|
+
self.max_retries = max_retries
|
|
63
|
+
|
|
64
|
+
from .datasets import Datasets
|
|
65
|
+
from .jobs import Jobs
|
|
66
|
+
self.datasets = Datasets(self)
|
|
67
|
+
self.jobs = Jobs(self)
|
|
68
|
+
|
|
69
|
+
def me(self) -> Account:
|
|
70
|
+
"""The account the key belongs to, with its balance and the price list."""
|
|
71
|
+
return Account.model_validate(self._get("/v1/me"))
|
|
72
|
+
|
|
73
|
+
def models(self) -> list[ModelInfo]:
|
|
74
|
+
"""Every model a job can train, live: methods, longest sample, price, GPU setups and whether one is free now."""
|
|
75
|
+
return [ModelInfo.model_validate(x) for x in self._get("/v1/models")]
|
|
76
|
+
|
|
77
|
+
def layouts(self) -> list[Layout]:
|
|
78
|
+
"""The GPU setups, the models each trains, and whether one is free now."""
|
|
79
|
+
return [Layout.model_validate(x) for x in self._get("/v1/layouts")]
|
|
80
|
+
|
|
81
|
+
def close(self) -> None:
|
|
82
|
+
self._http.close()
|
|
83
|
+
|
|
84
|
+
def __enter__(self) -> NexTron:
|
|
85
|
+
return self
|
|
86
|
+
|
|
87
|
+
def __exit__(self, *exc: object) -> None:
|
|
88
|
+
self.close()
|
|
89
|
+
|
|
90
|
+
def _request(self, method: str, path: str, *, retry: bool | None = None, **kwargs: Any) -> httpx.Response:
|
|
91
|
+
"""Send one request. GETs, and POSTs the caller marks safe to repeat, are retried on a dropped connection or a
|
|
92
|
+
429/502/503/504."""
|
|
93
|
+
if retry is None:
|
|
94
|
+
retry = method == "GET"
|
|
95
|
+
attempts = self.max_retries + 1 if retry else 1
|
|
96
|
+
for attempt in range(attempts):
|
|
97
|
+
last = attempt == attempts - 1
|
|
98
|
+
try:
|
|
99
|
+
r = self._http.request(method, path, **kwargs)
|
|
100
|
+
except httpx.TransportError:
|
|
101
|
+
if last:
|
|
102
|
+
raise
|
|
103
|
+
time.sleep(2**attempt)
|
|
104
|
+
continue
|
|
105
|
+
if r.status_code in RETRY_STATUS and not last:
|
|
106
|
+
time.sleep(float(r.headers.get("Retry-After", 2**attempt)))
|
|
107
|
+
continue
|
|
108
|
+
if r.is_error:
|
|
109
|
+
raise _api_error(r)
|
|
110
|
+
return r
|
|
111
|
+
raise AssertionError("unreachable")
|
|
112
|
+
|
|
113
|
+
def _get(self, path: str, **kwargs: Any) -> Any:
|
|
114
|
+
return self._request("GET", path, **kwargs).json()
|
|
115
|
+
|
|
116
|
+
def _post(self, path: str, body: Any = None, **kwargs: Any) -> Any:
|
|
117
|
+
return self._request("POST", path, json=body, **kwargs).json()
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def poll(get: Callable[[], T], done: Callable[[T], bool], timeout: float | None, interval: float) -> T:
|
|
121
|
+
"""Call `get` every `interval` seconds until `done` holds; TimeoutError after `timeout` seconds."""
|
|
122
|
+
deadline = None if timeout is None else time.monotonic() + timeout
|
|
123
|
+
while True:
|
|
124
|
+
value = get()
|
|
125
|
+
if done(value):
|
|
126
|
+
return value
|
|
127
|
+
if deadline is not None and time.monotonic() + interval > deadline:
|
|
128
|
+
raise TimeoutError(f"still not done after {timeout} s")
|
|
129
|
+
time.sleep(interval)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0rc1"
|
nextron_cloud/cli.py
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"""`nextron-cloud`: the SDK from a shell. Every command prints JSON."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel
|
|
12
|
+
|
|
13
|
+
from ._client import APIError, NexTron, NexTronError
|
|
14
|
+
from . import experiments
|
|
15
|
+
from .data import check_file, prepare_file
|
|
16
|
+
from .types import TrainingParams, Wandb
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _print(value: Any) -> None:
|
|
20
|
+
if isinstance(value, BaseModel):
|
|
21
|
+
value = value.model_dump(mode="json")
|
|
22
|
+
elif isinstance(value, list):
|
|
23
|
+
value = [v.model_dump(mode="json") if isinstance(v, BaseModel) else v for v in value]
|
|
24
|
+
print(json.dumps(value, indent=2, ensure_ascii=False))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _value(text: str) -> Any:
|
|
28
|
+
"""A value that parses as JSON (numbers, true/false) is taken as that, else as text."""
|
|
29
|
+
try:
|
|
30
|
+
return json.loads(text)
|
|
31
|
+
except json.JSONDecodeError:
|
|
32
|
+
return text
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _params(pairs: list[str]) -> TrainingParams:
|
|
36
|
+
"""`key=value` pairs."""
|
|
37
|
+
params: dict[str, Any] = {}
|
|
38
|
+
for pair in pairs:
|
|
39
|
+
key, sep, value = pair.partition("=")
|
|
40
|
+
if not sep:
|
|
41
|
+
raise SystemExit(f"--param takes key=value, got {pair!r}")
|
|
42
|
+
params[key] = _value(value)
|
|
43
|
+
return params # type: ignore[return-value]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _draft(args: argparse.Namespace) -> dict[str, Any]:
|
|
47
|
+
return {"model": args.model, "dataset_id": args.dataset, "layout": args.layout, "params": _params(args.param)}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _grid(pairs: list[str]) -> dict[str, list[Any]]:
|
|
51
|
+
"""`key=v1,v2,...` per axis, each value parsed like --param."""
|
|
52
|
+
axes = {}
|
|
53
|
+
for pair in pairs:
|
|
54
|
+
key, sep, values = pair.partition("=")
|
|
55
|
+
if not sep:
|
|
56
|
+
raise SystemExit(f"--grid takes key=v1,v2,..., got {pair!r}")
|
|
57
|
+
axes[key] = [_value(value) for value in values.split(",")]
|
|
58
|
+
return axes
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _sweep(client: NexTron, args: argparse.Namespace) -> Any:
|
|
62
|
+
rows = experiments.sweep(client, name=args.name, model=args.model, dataset_id=args.dataset, axes=_grid(args.grid),
|
|
63
|
+
params=_params(args.param), layout=args.layout, start=args.yes)
|
|
64
|
+
if args.yes:
|
|
65
|
+
return {"tag": experiments.sweep_tag(args.name), "jobs": rows}
|
|
66
|
+
unpriced = [row["name"] for row in rows if row["estimate"]["credits"] is None]
|
|
67
|
+
total = None if unpriced else sum(row["estimate"]["credits"] for row in rows)
|
|
68
|
+
_print({"jobs": rows, "total_credits": total, "unpriced": unpriced, "created": False,
|
|
69
|
+
"next": "show the total to the owner; rerun with --yes to start every job"})
|
|
70
|
+
sys.exit(3)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _report(client: NexTron, args: argparse.Namespace) -> Any:
|
|
74
|
+
if not args.tag and not args.job_id:
|
|
75
|
+
raise SystemExit("report needs --tag or job ids")
|
|
76
|
+
rows = experiments.report(client, tag=args.tag, job_ids=args.job_id)
|
|
77
|
+
if not args.table:
|
|
78
|
+
return rows
|
|
79
|
+
columns = ["id", "name", "state", "steps", "loss", "best_val_loss", "best_val_step", "tokens_trained", "charged_credits",
|
|
80
|
+
"training_minutes", "checkpoint_ready"]
|
|
81
|
+
print("\t".join(columns))
|
|
82
|
+
for row in rows:
|
|
83
|
+
print("\t".join("" if row[c] is None else str(row[c]) for c in columns))
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _create(client: NexTron, args: argparse.Namespace) -> Any:
|
|
88
|
+
draft = _draft(args)
|
|
89
|
+
if not args.yes:
|
|
90
|
+
estimate = client.jobs.estimate(**draft)
|
|
91
|
+
_print({"estimate": estimate.model_dump(mode="json"), "created": False,
|
|
92
|
+
"next": "show the cost to the owner; rerun with --yes to start the job"})
|
|
93
|
+
sys.exit(3)
|
|
94
|
+
wandb: Wandb | None = None
|
|
95
|
+
if args.wandb_project:
|
|
96
|
+
if not os.environ.get("WANDB_API_KEY"):
|
|
97
|
+
raise SystemExit("--wandb-project needs WANDB_API_KEY in the environment")
|
|
98
|
+
wandb = {"api_key": os.environ["WANDB_API_KEY"], "project": args.wandb_project}
|
|
99
|
+
if args.wandb_run_name:
|
|
100
|
+
wandb["run_name"] = args.wandb_run_name
|
|
101
|
+
return client.jobs.create(**draft, name=args.name, tags=args.tag or None, wandb=wandb, idempotency_key=args.idempotency_key)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _guide_dir() -> Path:
|
|
105
|
+
"""The guide inside the installed package, or in the repository for a development install."""
|
|
106
|
+
packaged = Path(__file__).parent / "guide"
|
|
107
|
+
return packaged if packaged.exists() else Path(__file__).parents[2] / "skills" / "nextron-cloud"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _guide(args: argparse.Namespace) -> tuple[None, int]:
|
|
111
|
+
root = _guide_dir()
|
|
112
|
+
path = root / "SKILL.md" if not args.topic else root / "references" / f"{args.topic}.md"
|
|
113
|
+
if not path.exists():
|
|
114
|
+
topics = sorted(p.stem for p in (root / "references").glob("*.md"))
|
|
115
|
+
raise SystemExit(f"no guide topic {args.topic!r}; topics: {', '.join(topics)}")
|
|
116
|
+
print(path.read_text(encoding="utf-8"))
|
|
117
|
+
return None, 0
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _events(client: NexTron, args: argparse.Namespace) -> None:
|
|
121
|
+
for event in client.jobs.events(args.job_id, after=args.after, follow=args.follow, until=args.until):
|
|
122
|
+
print(json.dumps(event.model_dump(mode="json"), ensure_ascii=False), flush=True)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _logs(client: NexTron, args: argparse.Namespace) -> None:
|
|
126
|
+
for line in client.jobs.logs(args.job_id, follow=args.follow):
|
|
127
|
+
print(line.line, flush=True)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _parser() -> argparse.ArgumentParser:
|
|
131
|
+
parser = argparse.ArgumentParser(prog="nextron-cloud", description="NexTron Cloud from the shell. Reads NEXTRON_API_KEY.")
|
|
132
|
+
groups = parser.add_subparsers(dest="group", required=True)
|
|
133
|
+
|
|
134
|
+
cmd = groups.add_parser("guide", help="how to run experiments: read this first (no key needed)")
|
|
135
|
+
cmd.add_argument("topic", nargs="?", help="data, training, monitoring, checkpoints or api")
|
|
136
|
+
cmd.set_defaults(run=None, local=_guide)
|
|
137
|
+
groups.add_parser("me", help="account, balance (US cents), price list").set_defaults(run=lambda c, a: c.me())
|
|
138
|
+
groups.add_parser("models", help="models a job can train, live").set_defaults(run=lambda c, a: c.models())
|
|
139
|
+
groups.add_parser("layouts", help="GPU setups").set_defaults(run=lambda c, a: c.layouts())
|
|
140
|
+
|
|
141
|
+
datasets = groups.add_parser("datasets", help="upload, import, check and list training data").add_subparsers(dest="cmd", required=True)
|
|
142
|
+
datasets.add_parser("list").set_defaults(run=lambda c, a: c.datasets.list())
|
|
143
|
+
cmd = datasets.add_parser("get")
|
|
144
|
+
cmd.add_argument("dataset_id")
|
|
145
|
+
cmd.set_defaults(run=lambda c, a: c.datasets.get(a.dataset_id))
|
|
146
|
+
cmd = datasets.add_parser("check", help="the server's format check, locally; no key needed")
|
|
147
|
+
cmd.add_argument("path")
|
|
148
|
+
cmd.set_defaults(run=None, local=lambda a: _checked(check_file(a.path)))
|
|
149
|
+
cmd = datasets.add_parser("prepare", help="convert a .jsonl/.json/.csv file to train.jsonl and val.jsonl; no key needed")
|
|
150
|
+
cmd.add_argument("path")
|
|
151
|
+
cmd.add_argument("--out-dir", default=".")
|
|
152
|
+
cmd.add_argument("--val-fraction", type=float, default=0.1)
|
|
153
|
+
cmd.add_argument("--seed", type=int, default=0)
|
|
154
|
+
cmd.add_argument("--prompt-column", action="append", help="the user message's column (with --response-column); "
|
|
155
|
+
"repeat to join several, e.g. instruction and context")
|
|
156
|
+
cmd.add_argument("--response-column", help="the assistant message's column")
|
|
157
|
+
cmd.add_argument("--system", help="a system message for every sample")
|
|
158
|
+
cmd.set_defaults(run=None, local=lambda a: (prepare_file(
|
|
159
|
+
a.path, a.out_dir, val_fraction=a.val_fraction, seed=a.seed, prompt_column=a.prompt_column,
|
|
160
|
+
response_column=a.response_column, system=a.system), 0))
|
|
161
|
+
cmd = datasets.add_parser("upload", help="check, upload and wait for the server's check")
|
|
162
|
+
cmd.add_argument("path")
|
|
163
|
+
cmd.add_argument("--name")
|
|
164
|
+
cmd.set_defaults(run=lambda c, a: c.datasets.wait(c.datasets.upload(a.path, name=a.name).id))
|
|
165
|
+
cmd = datasets.add_parser("splits", help="a Hugging Face dataset's configs and splits")
|
|
166
|
+
cmd.add_argument("url")
|
|
167
|
+
cmd.set_defaults(run=lambda c, a: c.datasets.huggingface_splits(a.url))
|
|
168
|
+
cmd = datasets.add_parser("import", help="import a Hugging Face split and wait for its check")
|
|
169
|
+
cmd.add_argument("url")
|
|
170
|
+
cmd.add_argument("--config", default="default")
|
|
171
|
+
cmd.add_argument("--split", default="train")
|
|
172
|
+
cmd.add_argument("--name")
|
|
173
|
+
cmd.set_defaults(run=lambda c, a: c.datasets.wait(
|
|
174
|
+
c.datasets.import_huggingface(a.url, config=a.config, split=a.split, name=a.name).id))
|
|
175
|
+
cmd = datasets.add_parser("download-url")
|
|
176
|
+
cmd.add_argument("dataset_id")
|
|
177
|
+
cmd.set_defaults(run=lambda c, a: {"url": c.datasets.download_url(a.dataset_id)})
|
|
178
|
+
cmd = datasets.add_parser("delete")
|
|
179
|
+
cmd.add_argument("dataset_id")
|
|
180
|
+
cmd.set_defaults(run=lambda c, a: c.datasets.delete(a.dataset_id))
|
|
181
|
+
|
|
182
|
+
jobs = groups.add_parser("jobs", help="estimate, start and watch training jobs").add_subparsers(dest="cmd", required=True)
|
|
183
|
+
for name in ("estimate", "create"):
|
|
184
|
+
cmd = jobs.add_parser(name, help="cost of a job" if name == "estimate" else "estimate, then start with --yes")
|
|
185
|
+
cmd.add_argument("--model", required=True)
|
|
186
|
+
cmd.add_argument("--dataset", required=True, help="dataset id")
|
|
187
|
+
cmd.add_argument("--layout", default="auto")
|
|
188
|
+
cmd.add_argument("--param", action="append", default=[], metavar="KEY=VALUE", help="a training param; repeat")
|
|
189
|
+
if name == "estimate":
|
|
190
|
+
cmd.set_defaults(run=lambda c, a: c.jobs.estimate(**_draft(a)))
|
|
191
|
+
continue
|
|
192
|
+
cmd.add_argument("--yes", action="store_true", help="start the job; without it only the estimate is printed")
|
|
193
|
+
cmd.add_argument("--name", help="your label for the job")
|
|
194
|
+
cmd.add_argument("--tag", action="append", help="a tag to group jobs by; repeat")
|
|
195
|
+
cmd.add_argument("--idempotency-key", help="the same key never starts a second job")
|
|
196
|
+
cmd.add_argument("--wandb-project", help="log to this W&B project; the key comes from WANDB_API_KEY")
|
|
197
|
+
cmd.add_argument("--wandb-run-name")
|
|
198
|
+
cmd.set_defaults(run=_create)
|
|
199
|
+
cmd = jobs.add_parser("list")
|
|
200
|
+
cmd.add_argument("--tag")
|
|
201
|
+
cmd.set_defaults(run=lambda c, a: c.jobs.list(tag=a.tag))
|
|
202
|
+
for name, run in (("get", lambda c, a: c.jobs.get(a.job_id)), ("wait", lambda c, a: c.jobs.wait(a.job_id)),
|
|
203
|
+
("wait-checkpoint", lambda c, a: c.jobs.wait_checkpoint(a.job_id)),
|
|
204
|
+
("progress", lambda c, a: c.jobs.progress(a.job_id)), ("billing", lambda c, a: c.jobs.billing(a.job_id)),
|
|
205
|
+
("metrics", lambda c, a: c.jobs.metrics(a.job_id)), ("cancel", lambda c, a: c.jobs.cancel(a.job_id)),
|
|
206
|
+
("checkpoint", lambda c, a: c.jobs.checkpoint(a.job_id))):
|
|
207
|
+
cmd = jobs.add_parser(name)
|
|
208
|
+
cmd.add_argument("job_id")
|
|
209
|
+
cmd.set_defaults(run=run)
|
|
210
|
+
cmd = jobs.add_parser("events", help="the job's events as JSON lines")
|
|
211
|
+
cmd.add_argument("job_id")
|
|
212
|
+
cmd.add_argument("--after", type=int, default=0, help="the last seq already read")
|
|
213
|
+
cmd.add_argument("--follow", action="store_true", help="keep reading until training is over")
|
|
214
|
+
cmd.add_argument("--until", choices=["training", "checkpoint"], default="training")
|
|
215
|
+
cmd.set_defaults(run=_events)
|
|
216
|
+
cmd = jobs.add_parser("logs")
|
|
217
|
+
cmd.add_argument("job_id")
|
|
218
|
+
cmd.add_argument("--follow", action="store_true", help="keep reading until the job ends")
|
|
219
|
+
cmd.set_defaults(run=_logs)
|
|
220
|
+
cmd = jobs.add_parser("download", help="download the checkpoint and check every file's sha256")
|
|
221
|
+
cmd.add_argument("job_id")
|
|
222
|
+
cmd.add_argument("dest")
|
|
223
|
+
cmd.add_argument("--include", action="append", metavar="GLOB", help="only files matching this pattern; repeat")
|
|
224
|
+
cmd.set_defaults(run=lambda c, a: {"path": str(c.jobs.download_checkpoint(a.job_id, a.dest, include=a.include))})
|
|
225
|
+
cmd = jobs.add_parser("export", help="save the job record as a zip")
|
|
226
|
+
cmd.add_argument("job_id")
|
|
227
|
+
cmd.add_argument("dest")
|
|
228
|
+
cmd.set_defaults(run=lambda c, a: {"path": str(c.jobs.export(a.job_id, a.dest))})
|
|
229
|
+
|
|
230
|
+
cmd = groups.add_parser("sweep", help="one job per combination of --grid values; estimates, then starts with --yes")
|
|
231
|
+
cmd.add_argument("--name", required=True, help="the sweep's name; its jobs are tagged sweep:<name>")
|
|
232
|
+
cmd.add_argument("--model", required=True)
|
|
233
|
+
cmd.add_argument("--dataset", required=True, help="dataset id")
|
|
234
|
+
cmd.add_argument("--layout", default="auto")
|
|
235
|
+
cmd.add_argument("--param", action="append", default=[], metavar="KEY=VALUE", help="a param every job gets; repeat")
|
|
236
|
+
cmd.add_argument("--grid", action="append", required=True, metavar="KEY=V1,V2", help="values to sweep; repeat")
|
|
237
|
+
cmd.add_argument("--yes", action="store_true", help="start the jobs; without it only the estimates are printed")
|
|
238
|
+
cmd.set_defaults(run=_sweep)
|
|
239
|
+
cmd = groups.add_parser("report", help="compare jobs: settings, losses, tokens, time")
|
|
240
|
+
cmd.add_argument("job_id", nargs="*")
|
|
241
|
+
cmd.add_argument("--tag", help="every job with this tag, e.g. sweep:<name>")
|
|
242
|
+
cmd.add_argument("--table", action="store_true", help="tab-separated text instead of JSON")
|
|
243
|
+
cmd.set_defaults(run=_report)
|
|
244
|
+
return parser
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _checked(result: Any) -> tuple[dict[str, Any], int]:
|
|
248
|
+
return {"ok": result.ok, "samples": result.samples, "chars": result.chars, "errors": result.errors}, 0 if result.ok else 1
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def main(argv: list[str] | None = None) -> None:
|
|
252
|
+
args = _parser().parse_args(argv)
|
|
253
|
+
if args.run is None:
|
|
254
|
+
output, code = args.local(args)
|
|
255
|
+
if output is not None:
|
|
256
|
+
_print(output)
|
|
257
|
+
sys.exit(code)
|
|
258
|
+
try:
|
|
259
|
+
with NexTron() as client:
|
|
260
|
+
result = args.run(client, args)
|
|
261
|
+
except APIError as error:
|
|
262
|
+
_print({"error": error.code, "status": error.status_code, "message": error.message})
|
|
263
|
+
sys.exit(1)
|
|
264
|
+
except NexTronError as error:
|
|
265
|
+
_print({"error": str(error)})
|
|
266
|
+
sys.exit(1)
|
|
267
|
+
if result is not None:
|
|
268
|
+
_print(result)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
if __name__ == "__main__":
|
|
272
|
+
main()
|
nextron_cloud/data.py
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""Training data: the sample format, conversion from common formats, and the server's format check run locally.
|
|
2
|
+
|
|
3
|
+
A dataset is a JSONL file, one sample per line, text only:
|
|
4
|
+
|
|
5
|
+
{"messages": [{"role": "system", "content": "..."}, {"role": "user", "content": "..."},
|
|
6
|
+
{"role": "assistant", "content": "..."}]}
|
|
7
|
+
|
|
8
|
+
At least one assistant message is needed, and a system message may only come first. These formats are converted to it
|
|
9
|
+
(here by `to_messages`, and by the server on upload):
|
|
10
|
+
|
|
11
|
+
{"conversations" | "conversation": [{"from"|"role": ..., "value"|"content": ...}]} ShareGPT, LMSYS
|
|
12
|
+
{"instruction": ..., "input" | "context": ..., "output" | "response": ...} Alpaca, Dolly
|
|
13
|
+
{"prompt": ..., "completion" | "response": ...} prompt / completion
|
|
14
|
+
{"query" | "question": ..., "response" | "answer": ...} question / answer (OpenOrca)
|
|
15
|
+
{"problem": ..., "solution": ...}, {"input": ..., "output": ...} problem / solution
|
|
16
|
+
"content": [{"type": "text", "text": ...}, ...] OpenAI content parts, text only
|
|
17
|
+
|
|
18
|
+
An optional top-level "system" or "system_prompt" string becomes the system message.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
from collections.abc import Iterable
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
Message = dict[str, str]
|
|
30
|
+
|
|
31
|
+
ROLES = ("system", "user", "assistant")
|
|
32
|
+
SHAREGPT_ROLES = {"human": "user", "user": "user", "gpt": "assistant", "assistant": "assistant", "model": "assistant",
|
|
33
|
+
"system": "system"}
|
|
34
|
+
PAIRS = (("prompt", "completion"), ("prompt", "response"), ("query", "response"), ("question", "answer"), ("question", "response"),
|
|
35
|
+
("problem", "solution"), ("input", "output"))
|
|
36
|
+
MEDIA_FIELDS = ("image", "images", "video", "videos", "audio", "audios")
|
|
37
|
+
MEDIA_ERROR = "has an image, audio or video part; NexTron Cloud trains on text only for now"
|
|
38
|
+
MAX_LINE_BYTES = 4 << 20
|
|
39
|
+
MAX_FILE_BYTES = 5 * 1000**3
|
|
40
|
+
MAX_ERRORS = 5 # the server also reports only the first five
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _text(content: Any) -> str:
|
|
44
|
+
if isinstance(content, str):
|
|
45
|
+
return content
|
|
46
|
+
if isinstance(content, list) and all(isinstance(p, dict) for p in content):
|
|
47
|
+
if any(p.get("type") != "text" for p in content):
|
|
48
|
+
raise ValueError(MEDIA_ERROR)
|
|
49
|
+
return "".join(str(p.get("text", "")) for p in content)
|
|
50
|
+
raise ValueError('every message needs a text "content"')
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def to_messages(sample: Any) -> list[Message]:
|
|
54
|
+
"""The sample as chat messages. ValueError says why it cannot be trained on."""
|
|
55
|
+
if isinstance(sample, list):
|
|
56
|
+
raise ValueError("is a JSON array; upload JSONL: one JSON object per line")
|
|
57
|
+
if not isinstance(sample, dict):
|
|
58
|
+
raise ValueError("is not a JSON object")
|
|
59
|
+
if any(sample.get(f) for f in MEDIA_FIELDS):
|
|
60
|
+
raise ValueError(MEDIA_ERROR)
|
|
61
|
+
given = next((sample[k] for k in ("system", "system_prompt") if isinstance(sample.get(k), str) and sample[k]), None)
|
|
62
|
+
system = [{"role": "system", "content": given}] if given else []
|
|
63
|
+
if "messages" in sample:
|
|
64
|
+
source = sample["messages"]
|
|
65
|
+
if not isinstance(source, list) or not source:
|
|
66
|
+
raise ValueError('needs a non-empty "messages" list')
|
|
67
|
+
messages = []
|
|
68
|
+
for m in source:
|
|
69
|
+
if not isinstance(m, dict) or m.get("role") not in ROLES:
|
|
70
|
+
raise ValueError('every message needs a "role" (system, user or assistant)')
|
|
71
|
+
messages.append({"role": m["role"], "content": _text(m.get("content"))})
|
|
72
|
+
elif "conversations" in sample or "conversation" in sample:
|
|
73
|
+
turns = sample.get("conversations", sample.get("conversation"))
|
|
74
|
+
if not isinstance(turns, list) or not turns:
|
|
75
|
+
raise ValueError('needs a non-empty "conversations" list')
|
|
76
|
+
messages = []
|
|
77
|
+
for t in turns:
|
|
78
|
+
role = SHAREGPT_ROLES.get(str(t.get("from", t.get("role", ""))).lower()) if isinstance(t, dict) else None
|
|
79
|
+
if role is None:
|
|
80
|
+
raise ValueError('every turn needs "from" (human, gpt or system) and "value"')
|
|
81
|
+
messages.append({"role": role, "content": _text(t.get("value", t.get("content")))})
|
|
82
|
+
elif "instruction" in sample and ("output" in sample or "response" in sample):
|
|
83
|
+
answer = "output" if "output" in sample else "response"
|
|
84
|
+
prompt = "\n\n".join(str(sample[k]) for k in ("instruction", "input", "context") if sample.get(k))
|
|
85
|
+
messages = [{"role": "user", "content": prompt}, {"role": "assistant", "content": _text(sample[answer])}]
|
|
86
|
+
else:
|
|
87
|
+
pair = next((p for p in PAIRS if p[0] in sample and p[1] in sample), None)
|
|
88
|
+
if pair is None:
|
|
89
|
+
raise ValueError('is not a format we know: use "messages", or one of conversations, instruction/output, '
|
|
90
|
+
"instruction/response, prompt/completion, query/response, question/answer, problem/solution, input/output")
|
|
91
|
+
messages = [{"role": "user", "content": _text(sample[pair[0]])}, {"role": "assistant", "content": _text(sample[pair[1]])}]
|
|
92
|
+
if not any(m["role"] == "assistant" and m["content"] for m in messages):
|
|
93
|
+
raise ValueError("needs at least one non-empty assistant message to learn from")
|
|
94
|
+
messages = system + messages
|
|
95
|
+
if any(m["role"] == "system" for m in messages[1:]):
|
|
96
|
+
raise ValueError("has a system message that is not the first message; a sample can have one, first")
|
|
97
|
+
return messages
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def write_jsonl(samples: Iterable[Any], path: str | os.PathLike[str]) -> int:
|
|
101
|
+
"""Write samples in any format above as a "messages" JSONL file; returns how many. Raises on the first bad one."""
|
|
102
|
+
count = 0
|
|
103
|
+
with Path(path).open("w", encoding="utf-8") as out:
|
|
104
|
+
for number, sample in enumerate(samples, 1):
|
|
105
|
+
try:
|
|
106
|
+
messages = to_messages(sample)
|
|
107
|
+
except ValueError as error:
|
|
108
|
+
raise ValueError(f"sample {number}: {error}") from None
|
|
109
|
+
out.write(json.dumps({"messages": messages}, ensure_ascii=False) + "\n")
|
|
110
|
+
count += 1
|
|
111
|
+
return count
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclass
|
|
115
|
+
class CheckResult:
|
|
116
|
+
samples: int = 0
|
|
117
|
+
chars: int = 0 # message text; a token is roughly 3-4 characters of English
|
|
118
|
+
errors: list[str] = field(default_factory=list)
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def ok(self) -> bool:
|
|
122
|
+
return not self.errors
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def check_file(path: str | os.PathLike[str]) -> CheckResult:
|
|
126
|
+
"""Run the server's format check on a local JSONL file. Token counts per model come from the server after upload."""
|
|
127
|
+
path = Path(path)
|
|
128
|
+
result = CheckResult()
|
|
129
|
+
if path.stat().st_size > MAX_FILE_BYTES:
|
|
130
|
+
result.errors.append(f"the file is over {MAX_FILE_BYTES // 1000**3} GB")
|
|
131
|
+
return result
|
|
132
|
+
with path.open("rb") as f:
|
|
133
|
+
for number, raw in enumerate(f, 1):
|
|
134
|
+
if len(raw) > MAX_LINE_BYTES:
|
|
135
|
+
_note(result, f"line {number}: longer than {MAX_LINE_BYTES >> 20} MB")
|
|
136
|
+
continue
|
|
137
|
+
if number == 1:
|
|
138
|
+
raw = raw.removeprefix(b"\xef\xbb\xbf")
|
|
139
|
+
try:
|
|
140
|
+
line = raw.decode("utf-8").strip()
|
|
141
|
+
except UnicodeDecodeError:
|
|
142
|
+
_note(result, f"line {number}: not UTF-8 text; save the file as UTF-8")
|
|
143
|
+
continue
|
|
144
|
+
if not line:
|
|
145
|
+
continue
|
|
146
|
+
try:
|
|
147
|
+
messages = to_messages(json.loads(line))
|
|
148
|
+
except json.JSONDecodeError as error:
|
|
149
|
+
_note(result, f"line {number}: not valid JSON ({error.msg})")
|
|
150
|
+
continue
|
|
151
|
+
except ValueError as error:
|
|
152
|
+
_note(result, f"line {number}: {error}")
|
|
153
|
+
continue
|
|
154
|
+
result.samples += 1
|
|
155
|
+
result.chars += sum(len(m["content"]) for m in messages)
|
|
156
|
+
if not result.errors and not result.samples:
|
|
157
|
+
result.errors.append("the file has no samples")
|
|
158
|
+
return result
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _note(result: CheckResult, error: str) -> None:
|
|
162
|
+
if len(result.errors) < MAX_ERRORS:
|
|
163
|
+
result.errors.append(error)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
VAL_MAX_SAMPLES = 5000 # the most a validation set may have
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def read_rows(path: str | os.PathLike[str]) -> list[Any]:
|
|
170
|
+
"""Rows of a .jsonl, .json (a list) or .csv file, as parsed values (CSV rows as dicts of strings)."""
|
|
171
|
+
path = Path(path)
|
|
172
|
+
if path.suffix == ".csv":
|
|
173
|
+
import csv
|
|
174
|
+
with path.open(newline="", encoding="utf-8-sig") as f:
|
|
175
|
+
return list(csv.DictReader(f))
|
|
176
|
+
if path.suffix == ".json":
|
|
177
|
+
rows = json.loads(path.read_text(encoding="utf-8"))
|
|
178
|
+
if not isinstance(rows, list):
|
|
179
|
+
raise ValueError(f"{path.name} is not a JSON list of samples")
|
|
180
|
+
return rows
|
|
181
|
+
with path.open(encoding="utf-8-sig") as f:
|
|
182
|
+
return [json.loads(line) for line in f if line.strip()]
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def prepare_file(path: str | os.PathLike[str], out_dir: str | os.PathLike[str] = ".", *, val_fraction: float = 0.1,
|
|
186
|
+
seed: int = 0, prompt_column: str | list[str] | None = None, response_column: str | None = None,
|
|
187
|
+
system: str | None = None) -> dict[str, Any]:
|
|
188
|
+
"""Convert a file to train.jsonl and val.jsonl in `out_dir`: rows in any accepted format, or user and assistant
|
|
189
|
+
columns (`prompt_column`, several joined by a blank line when non-empty; `response_column`), e.g. of a CSV. Rows that cannot be trained on are left out
|
|
190
|
+
and counted. Shuffled with `seed`; the validation set is `val_fraction` of the rows, at most 5,000."""
|
|
191
|
+
import random
|
|
192
|
+
|
|
193
|
+
if not 0 <= val_fraction < 1:
|
|
194
|
+
raise ValueError("val_fraction must be at least 0 and below 1")
|
|
195
|
+
rows = read_rows(path)
|
|
196
|
+
prompt_columns = [prompt_column] if isinstance(prompt_column, str) else prompt_column or []
|
|
197
|
+
if prompt_columns and response_column and rows:
|
|
198
|
+
missing = [c for c in (*prompt_columns, response_column) if c not in rows[0]]
|
|
199
|
+
if missing:
|
|
200
|
+
raise ValueError(f"{Path(path).name} has no column {', '.join(missing)}; it has {', '.join(rows[0])}")
|
|
201
|
+
samples: list[dict[str, Any]] = []
|
|
202
|
+
errors: list[str] = []
|
|
203
|
+
skipped = 0
|
|
204
|
+
for number, row in enumerate(rows, 1):
|
|
205
|
+
if prompt_columns and response_column:
|
|
206
|
+
prompt = "\n\n".join(str(row[c]) for c in prompt_columns if row[c])
|
|
207
|
+
row = {"prompt": prompt, "completion": row[response_column]}
|
|
208
|
+
if system:
|
|
209
|
+
row = dict(row, system=system)
|
|
210
|
+
try:
|
|
211
|
+
samples.append({"messages": to_messages(row)})
|
|
212
|
+
except ValueError as error:
|
|
213
|
+
skipped += 1
|
|
214
|
+
if len(errors) < MAX_ERRORS:
|
|
215
|
+
errors.append(f"row {number}: {error}")
|
|
216
|
+
random.Random(seed).shuffle(samples)
|
|
217
|
+
n_val = min(round(len(samples) * val_fraction), VAL_MAX_SAMPLES) if val_fraction else 0
|
|
218
|
+
out = Path(out_dir)
|
|
219
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
220
|
+
report: dict[str, Any] = {"skipped": skipped, "errors": errors}
|
|
221
|
+
for name, part in (("train", samples[n_val:]), ("val", samples[:n_val])):
|
|
222
|
+
target = out / f"{name}.jsonl"
|
|
223
|
+
target.unlink(missing_ok=True) # never leave an earlier run's file behind to be uploaded by mistake
|
|
224
|
+
if part:
|
|
225
|
+
write_jsonl(part, target)
|
|
226
|
+
report[name] = {"path": str(target), "samples": len(part)}
|
|
227
|
+
return report
|