heulistic 0.1.0__tar.gz

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,43 @@
1
+ # Secrets — never commit actual env files
2
+ .env
3
+ .env.local
4
+ *.env.local
5
+ .env.local.example
6
+
7
+ # Python
8
+ __pycache__/
9
+ *.py[cod]
10
+ .venv/
11
+ *.egg-info/
12
+ dist/
13
+ .pytest_cache/
14
+
15
+ # Node
16
+ node_modules/
17
+ .next/
18
+ out/
19
+
20
+ # Terraform
21
+ **/.terraform/
22
+ *.tfstate
23
+ *.tfstate.backup
24
+ *.tfvars
25
+ !*.tfvars.example
26
+
27
+ # OS
28
+ .DS_Store
29
+ Thumbs.db
30
+
31
+ # miscellaneous files
32
+ prd.txt
33
+ config.yaml
34
+ ARCHITECTURE.md
35
+ qwen-config.yaml
36
+ stripe-usage-billing.md
37
+
38
+ # The scratch copy at the repo root is ignored, but the reference vendored into
39
+ # heulistic-axolotl is the generator's input and must be committed. Without the
40
+ # negation the bare pattern matched it at any depth, so it was silently absent
41
+ # from CI while present locally.
42
+ axolotl.yaml
43
+ !packages/heulistic-axolotl/reference/axolotl.yaml
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Heulistic
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,169 @@
1
+ Metadata-Version: 2.5
2
+ Name: heulistic
3
+ Version: 0.1.0
4
+ Summary: Fine-tune LLMs without the infrastructure
5
+ Project-URL: Homepage, https://heulistic.com
6
+ Project-URL: Documentation, https://heulistic.com/docs
7
+ Author-email: Heulistic <hello@heulistic.com>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: axolotl,fine-tuning,llm,lora,machine-learning,qlora
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Intended Audience :: Science/Research
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
21
+ Requires-Python: >=3.10
22
+ Requires-Dist: heulistic-axolotl<0.2.0,>=0.1.0
23
+ Requires-Dist: typer>=0.12
24
+ Description-Content-Type: text/markdown
25
+
26
+ # heulistic
27
+
28
+ Fine-tune LLMs without the infrastructure.
29
+
30
+ Run the full Heulistic stack locally on your own hardware — no cloud account, no billing, no setup beyond Docker.
31
+
32
+ ## Installation
33
+
34
+ ```bash
35
+ pip install heulistic
36
+ ```
37
+
38
+ Requires Python 3.10+ and Docker Desktop (or Docker Engine + Compose plugin).
39
+
40
+ ## Quick start
41
+
42
+ ```bash
43
+ pip install heulistic
44
+
45
+ heulistic init # starter config, sized to your GPU
46
+ heulistic validate-config config.yaml # check it — offline, no Docker needed
47
+ heulistic start # bring the local stack up
48
+ heulistic train config.yaml # submit and follow the logs
49
+ heulistic download <job-id> # collect the trained model
50
+ ```
51
+
52
+ ## Training from the terminal
53
+
54
+ ```bash
55
+ heulistic train config.yaml
56
+ heulistic train config.yaml --dataset ./data.jsonl
57
+ heulistic train config.yaml --hf-token hf_... # gated base models
58
+ heulistic train config.yaml --detach # submit without following
59
+ heulistic train config.yaml --no-validate # skip the pre-flight check
60
+ ```
61
+
62
+ The config is validated locally first, so a typo costs nothing. Logs stream
63
+ until the run ends; Ctrl-C detaches without stopping the job. On success you
64
+ are told where to get the model.
65
+
66
+ ```bash
67
+ heulistic download <job-id> # ./<job-id>-model.tar.gz
68
+ heulistic download <job-id> -o model.tgz
69
+ heulistic download <job-id> --url-only
70
+ ```
71
+
72
+ ## Starting from a working config
73
+
74
+ ```bash
75
+ heulistic init # writes config.yaml
76
+ heulistic init -m Qwen/Qwen2.5-0.5B
77
+ heulistic init -o experiments/run1.yaml
78
+ ```
79
+
80
+ `init` reads your GPU and picks a base model that fits it, so your first run
81
+ does not OOM twenty minutes in. The generated file passes `validate-config`
82
+ with no findings.
83
+
84
+ ## Checking a config
85
+
86
+ Validate an Axolotl config before spending a GPU hour on it. Works offline —
87
+ no Docker, no running stack, no account.
88
+
89
+ ```bash
90
+ heulistic validate-config config.yaml
91
+ ```
92
+
93
+ ```
94
+ Checking config.yaml
95
+
96
+ error line 3 num_epochs num_epochs must be greater than 0
97
+ error line 5 micro_batch_size 'micro_batch_size' expects int | None, got str
98
+ warning line 2 lr Unknown field 'lr' — did you mean 'learning_rate'?
99
+ info bf16 'bf16' is not set — it defaults to 'auto'
100
+
101
+ 2 errors, 1 warning, 1 suggestion
102
+ ```
103
+
104
+ Field names, types, defaults and required-ness come from Axolotl's own
105
+ [config reference](https://docs.axolotl.ai/docs/config-reference.html), so the
106
+ checks track upstream rather than a hand-written list.
107
+
108
+ ```bash
109
+ # Check it also fits your GPU
110
+ heulistic validate-config config.yaml --gpu
111
+
112
+ # ...or a card you don't have in front of you
113
+ heulistic validate-config config.yaml --vram 24
114
+ heulistic validate-config config.yaml --vram 88 --gpus 4
115
+
116
+ # Fail on warnings too, for CI
117
+ heulistic validate-config config.yaml --strict
118
+
119
+ # Errors only, or JSON for tooling
120
+ heulistic validate-config config.yaml --quiet
121
+ heulistic validate-config config.yaml --json
122
+ ```
123
+
124
+ Exit codes: `0` clean, `1` problems found, `2` bad invocation.
125
+
126
+ ## Running the local stack
127
+
128
+ ```bash
129
+ # Start the stack (opens your browser automatically)
130
+ heulistic start
131
+
132
+ # GPU-less mode (CPU only — training will be slow)
133
+ heulistic start --no-gpu
134
+
135
+ # Use a HuggingFace token for gated models
136
+ heulistic start --hf-token hf_...
137
+
138
+ # Custom ports
139
+ heulistic start --port 3001 --api-port 8001
140
+
141
+ # What's running, and what are my jobs doing?
142
+ heulistic status
143
+
144
+ # View logs
145
+ heulistic logs
146
+ heulistic logs -f # follow
147
+
148
+ # Stop
149
+ heulistic stop
150
+ ```
151
+
152
+ `heulistic status` exits `0` only when every service is up, so it can gate a
153
+ script:
154
+
155
+ ```
156
+ Heulistic local stack
157
+
158
+ db running (healthy)
159
+ minio running
160
+ api running http://localhost:8000
161
+ scheduler running
162
+ frontend running http://localhost:3000
163
+
164
+ Jobs: 1 completed, 2 running
165
+ ```
166
+
167
+ ## Managed Cloud
168
+
169
+ Need more GPU or don't want to manage infrastructure? [Heulistic Cloud](https://heulistic.com) handles provisioning, scaling, and cost — pay only for what you use.
@@ -0,0 +1,144 @@
1
+ # heulistic
2
+
3
+ Fine-tune LLMs without the infrastructure.
4
+
5
+ Run the full Heulistic stack locally on your own hardware — no cloud account, no billing, no setup beyond Docker.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ pip install heulistic
11
+ ```
12
+
13
+ Requires Python 3.10+ and Docker Desktop (or Docker Engine + Compose plugin).
14
+
15
+ ## Quick start
16
+
17
+ ```bash
18
+ pip install heulistic
19
+
20
+ heulistic init # starter config, sized to your GPU
21
+ heulistic validate-config config.yaml # check it — offline, no Docker needed
22
+ heulistic start # bring the local stack up
23
+ heulistic train config.yaml # submit and follow the logs
24
+ heulistic download <job-id> # collect the trained model
25
+ ```
26
+
27
+ ## Training from the terminal
28
+
29
+ ```bash
30
+ heulistic train config.yaml
31
+ heulistic train config.yaml --dataset ./data.jsonl
32
+ heulistic train config.yaml --hf-token hf_... # gated base models
33
+ heulistic train config.yaml --detach # submit without following
34
+ heulistic train config.yaml --no-validate # skip the pre-flight check
35
+ ```
36
+
37
+ The config is validated locally first, so a typo costs nothing. Logs stream
38
+ until the run ends; Ctrl-C detaches without stopping the job. On success you
39
+ are told where to get the model.
40
+
41
+ ```bash
42
+ heulistic download <job-id> # ./<job-id>-model.tar.gz
43
+ heulistic download <job-id> -o model.tgz
44
+ heulistic download <job-id> --url-only
45
+ ```
46
+
47
+ ## Starting from a working config
48
+
49
+ ```bash
50
+ heulistic init # writes config.yaml
51
+ heulistic init -m Qwen/Qwen2.5-0.5B
52
+ heulistic init -o experiments/run1.yaml
53
+ ```
54
+
55
+ `init` reads your GPU and picks a base model that fits it, so your first run
56
+ does not OOM twenty minutes in. The generated file passes `validate-config`
57
+ with no findings.
58
+
59
+ ## Checking a config
60
+
61
+ Validate an Axolotl config before spending a GPU hour on it. Works offline —
62
+ no Docker, no running stack, no account.
63
+
64
+ ```bash
65
+ heulistic validate-config config.yaml
66
+ ```
67
+
68
+ ```
69
+ Checking config.yaml
70
+
71
+ error line 3 num_epochs num_epochs must be greater than 0
72
+ error line 5 micro_batch_size 'micro_batch_size' expects int | None, got str
73
+ warning line 2 lr Unknown field 'lr' — did you mean 'learning_rate'?
74
+ info bf16 'bf16' is not set — it defaults to 'auto'
75
+
76
+ 2 errors, 1 warning, 1 suggestion
77
+ ```
78
+
79
+ Field names, types, defaults and required-ness come from Axolotl's own
80
+ [config reference](https://docs.axolotl.ai/docs/config-reference.html), so the
81
+ checks track upstream rather than a hand-written list.
82
+
83
+ ```bash
84
+ # Check it also fits your GPU
85
+ heulistic validate-config config.yaml --gpu
86
+
87
+ # ...or a card you don't have in front of you
88
+ heulistic validate-config config.yaml --vram 24
89
+ heulistic validate-config config.yaml --vram 88 --gpus 4
90
+
91
+ # Fail on warnings too, for CI
92
+ heulistic validate-config config.yaml --strict
93
+
94
+ # Errors only, or JSON for tooling
95
+ heulistic validate-config config.yaml --quiet
96
+ heulistic validate-config config.yaml --json
97
+ ```
98
+
99
+ Exit codes: `0` clean, `1` problems found, `2` bad invocation.
100
+
101
+ ## Running the local stack
102
+
103
+ ```bash
104
+ # Start the stack (opens your browser automatically)
105
+ heulistic start
106
+
107
+ # GPU-less mode (CPU only — training will be slow)
108
+ heulistic start --no-gpu
109
+
110
+ # Use a HuggingFace token for gated models
111
+ heulistic start --hf-token hf_...
112
+
113
+ # Custom ports
114
+ heulistic start --port 3001 --api-port 8001
115
+
116
+ # What's running, and what are my jobs doing?
117
+ heulistic status
118
+
119
+ # View logs
120
+ heulistic logs
121
+ heulistic logs -f # follow
122
+
123
+ # Stop
124
+ heulistic stop
125
+ ```
126
+
127
+ `heulistic status` exits `0` only when every service is up, so it can gate a
128
+ script:
129
+
130
+ ```
131
+ Heulistic local stack
132
+
133
+ db running (healthy)
134
+ minio running
135
+ api running http://localhost:8000
136
+ scheduler running
137
+ frontend running http://localhost:3000
138
+
139
+ Jobs: 1 completed, 2 running
140
+ ```
141
+
142
+ ## Managed Cloud
143
+
144
+ Need more GPU or don't want to manage infrastructure? [Heulistic Cloud](https://heulistic.com) handles provisioning, scaling, and cost — pay only for what you use.
File without changes
@@ -0,0 +1,11 @@
1
+ """Entry point for `python -m heulistic`.
2
+
3
+ The `heulistic` console script is the normal way in; this makes the module form
4
+ work too, which is how the CLI gets run inside a virtualenv that has not been
5
+ activated.
6
+ """
7
+
8
+ from .main import main
9
+
10
+ if __name__ == "__main__":
11
+ main()
@@ -0,0 +1,161 @@
1
+ """Talk to the local Heulistic API.
2
+
3
+ Standard library only, so `pip install heulistic` stays light — which is why
4
+ multipart bodies are assembled here rather than handed to a library.
5
+
6
+ Local mode accepts a fixed bearer token in place of a Cognito JWT. When cloud
7
+ support lands this is the seam where a real credential goes.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import mimetypes
14
+ import secrets
15
+ import urllib.error
16
+ import urllib.request
17
+ from pathlib import Path
18
+ from typing import Iterator
19
+
20
+ __all__ = [
21
+ "ApiError",
22
+ "encode_multipart",
23
+ "submit",
24
+ "get_job",
25
+ "download_url",
26
+ "stream_logs",
27
+ ]
28
+
29
+ DEV_TOKEN = "dev-localuser"
30
+ _TIMEOUT = 30
31
+ # Training runs are long; the log stream must not time out mid-job.
32
+ _STREAM_TIMEOUT = 60 * 60 * 24
33
+
34
+
35
+ class ApiError(Exception):
36
+ """The API refused the request, carrying its `detail` where there is one."""
37
+
38
+
39
+ def _headers(content_type: str | None = None) -> dict[str, str]:
40
+ headers = {"Authorization": f"Bearer {DEV_TOKEN}"}
41
+ if content_type:
42
+ headers["Content-Type"] = content_type
43
+ return headers
44
+
45
+
46
+ def _raise_for_error(exc: urllib.error.HTTPError) -> None:
47
+ try:
48
+ detail = json.loads(exc.read()).get("detail", "")
49
+ except Exception:
50
+ detail = ""
51
+ raise ApiError(detail or f"HTTP {exc.code}") from exc
52
+
53
+
54
+ def _request(
55
+ method: str,
56
+ url: str,
57
+ *,
58
+ body: bytes | None = None,
59
+ content_type: str | None = None,
60
+ stream: bool = False,
61
+ ):
62
+ """Perform one HTTP request. Tests replace this wholesale."""
63
+ request = urllib.request.Request(
64
+ url, data=body, method=method, headers=_headers(content_type)
65
+ )
66
+ try:
67
+ response = urllib.request.urlopen(
68
+ request, timeout=_STREAM_TIMEOUT if stream else _TIMEOUT
69
+ )
70
+ except urllib.error.HTTPError as exc:
71
+ _raise_for_error(exc)
72
+ except urllib.error.URLError as exc:
73
+ raise ApiError(
74
+ f"Could not reach the Heulistic API at {url} — is it running? "
75
+ "Start it with: heulistic start"
76
+ ) from exc
77
+ return response if stream else response.read()
78
+
79
+
80
+ def encode_multipart(
81
+ fields: dict[str, str | None], files: dict[str, tuple[str, bytes]]
82
+ ) -> tuple[str, bytes]:
83
+ """Build a multipart/form-data body. Returns (content_type, body)."""
84
+ boundary = secrets.token_hex(16)
85
+ parts: list[bytes] = []
86
+
87
+ for name, value in fields.items():
88
+ if value in (None, ""):
89
+ continue # an empty optional field must not be sent at all
90
+ parts.append(
91
+ f"--{boundary}\r\n"
92
+ f'Content-Disposition: form-data; name="{name}"\r\n\r\n'
93
+ f"{value}\r\n".encode()
94
+ )
95
+
96
+ for name, (filename, content) in files.items():
97
+ mime = mimetypes.guess_type(filename)[0] or "application/octet-stream"
98
+ parts.append(
99
+ f"--{boundary}\r\n"
100
+ f'Content-Disposition: form-data; name="{name}"; filename="{filename}"\r\n'
101
+ f"Content-Type: {mime}\r\n\r\n".encode()
102
+ )
103
+ parts.append(content)
104
+ parts.append(b"\r\n")
105
+
106
+ parts.append(f"--{boundary}--\r\n".encode())
107
+ return f"multipart/form-data; boundary={boundary}", b"".join(parts)
108
+
109
+
110
+ def submit(
111
+ api_url: str,
112
+ config: Path,
113
+ dataset: Path | None = None,
114
+ hf_token: str | None = None,
115
+ ) -> dict:
116
+ """Submit a training job. Returns the API's job record."""
117
+ files = {"config": (config.name, config.read_bytes())}
118
+ if dataset is not None:
119
+ files["dataset"] = (dataset.name, dataset.read_bytes())
120
+
121
+ content_type, body = encode_multipart({"hf_token": hf_token}, files)
122
+ raw = _request(
123
+ "POST", f"{api_url}/api/jobs", body=body, content_type=content_type
124
+ )
125
+ return json.loads(raw)
126
+
127
+
128
+ def get_job(api_url: str, job_id: str) -> dict:
129
+ return json.loads(_request("GET", f"{api_url}/api/jobs/{job_id}"))
130
+
131
+
132
+ def download_url(api_url: str, job_id: str) -> str:
133
+ raw = _request("GET", f"{api_url}/api/jobs/{job_id}/download")
134
+ return json.loads(raw)["url"]
135
+
136
+
137
+ def _stream_lines(url: str) -> Iterator[bytes]:
138
+ response = _request("GET", url, stream=True)
139
+ for raw_line in response:
140
+ yield raw_line.rstrip(b"\n")
141
+
142
+
143
+ def stream_logs(api_url: str, job_id: str) -> Iterator[tuple[str, str]]:
144
+ """Yield ("line", text) for each log line, then ("done", status) at the end.
145
+
146
+ Server-sent events; anything that is not a well-formed `data:` frame — a
147
+ keepalive, a blank separator — is skipped rather than surfaced.
148
+ """
149
+ url = f"{api_url}/api/jobs/{job_id}/logs"
150
+ for raw_line in _stream_lines(url):
151
+ if not raw_line.startswith(b"data: "):
152
+ continue
153
+ try:
154
+ payload = json.loads(raw_line[len(b"data: "):])
155
+ except json.JSONDecodeError:
156
+ continue
157
+ if payload.get("event") == "done":
158
+ yield "done", payload.get("status", "")
159
+ return
160
+ if "line" in payload:
161
+ yield "line", payload["line"]
@@ -0,0 +1,92 @@
1
+ """`heulistic download` — fetch a finished model.
2
+
3
+ Locally the artifact sits in MinIO inside Docker, so without this the output of
4
+ a run is hard to reach. The API hands back a URL; this follows it.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import shutil
10
+ import urllib.error
11
+ import urllib.request
12
+ from pathlib import Path
13
+ from typing import Optional
14
+
15
+ import typer
16
+
17
+ from . import client, stack
18
+
19
+ _SUFFIX = "-model.tar.gz"
20
+
21
+
22
+ def _fetch(url: str, dest: Path) -> Path:
23
+ """Stream the artifact to disk. Replaced in tests."""
24
+ with urllib.request.urlopen(url, timeout=300) as response, open(dest, "wb") as out:
25
+ shutil.copyfileobj(response, out)
26
+ return dest
27
+
28
+
29
+ def _resolve_destination(output: Optional[Path], job_id: str) -> Path:
30
+ default_name = f"{job_id}{_SUFFIX}"
31
+ if output is None:
32
+ return Path.cwd() / default_name
33
+ if output.is_dir():
34
+ return output / default_name
35
+ return output
36
+
37
+
38
+ def download(
39
+ job_id: str = typer.Argument(..., help="Job ID to download the model for."),
40
+ output: Optional[Path] = typer.Option(
41
+ None, "--output", "-o", help="File or directory to write to."
42
+ ),
43
+ force: bool = typer.Option(False, "--force", "-f", help="Overwrite an existing file."),
44
+ url_only: bool = typer.Option(
45
+ False, "--url-only", help="Print the download URL instead of fetching it."
46
+ ),
47
+ ) -> None:
48
+ """Download the trained model for a completed job."""
49
+ api_url = stack.api_url()
50
+ if not api_url:
51
+ typer.secho("Heulistic is not running.", fg=typer.colors.RED)
52
+ typer.echo("Start it with: heulistic start")
53
+ raise typer.Exit(code=1)
54
+
55
+ try:
56
+ job = client.get_job(api_url, job_id)
57
+ except client.ApiError as exc:
58
+ typer.secho(str(exc), fg=typer.colors.RED)
59
+ raise typer.Exit(code=1)
60
+
61
+ status = job.get("status", "unknown")
62
+ if status != "completed":
63
+ typer.secho(f"Job {job_id} is '{status}' — a model is only available once "
64
+ "the job has completed.", fg=typer.colors.RED)
65
+ raise typer.Exit(code=1)
66
+
67
+ try:
68
+ url = client.download_url(api_url, job_id)
69
+ except client.ApiError as exc:
70
+ typer.secho(str(exc), fg=typer.colors.RED)
71
+ raise typer.Exit(code=1)
72
+
73
+ if url_only:
74
+ typer.echo(url)
75
+ raise typer.Exit(code=0)
76
+
77
+ dest = _resolve_destination(output, job_id)
78
+ if dest.exists() and not force:
79
+ typer.secho(f"{dest} already exists — pass --force to overwrite.",
80
+ fg=typer.colors.RED)
81
+ raise typer.Exit(code=1)
82
+
83
+ typer.echo(f"Downloading {job_id} → {dest}")
84
+ try:
85
+ _fetch(url, dest)
86
+ except (urllib.error.URLError, OSError) as exc:
87
+ typer.secho(f"Download failed: {exc}", fg=typer.colors.RED)
88
+ raise typer.Exit(code=1)
89
+
90
+ size_mb = dest.stat().st_size / (1024 * 1024)
91
+ typer.secho(f"Saved {dest} ({size_mb:.1f} MB)", fg=typer.colors.GREEN)
92
+ typer.echo(f" tar xzf {dest.name}")