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.
@@ -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__"]
@@ -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