tunekeep 0.2.1__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.
tunekeep/compare.py ADDED
@@ -0,0 +1,276 @@
1
+ """`tunekeep compare`: does another model already answer like your fine-tune?
2
+
3
+ Sends each recorded prompt to a candidate model (gpt-6-luna, any
4
+ OpenAI-compatible endpoint, or your retrained model on llama-server or vLLM),
5
+ optionally with a few examples from your own training data, and scores the
6
+ answer against the recorded one (and against the training reference if any).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import datetime as dt
12
+ import re
13
+ import statistics
14
+ import time
15
+ from dataclasses import dataclass
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ from .client import Client
20
+ from .record import load_prompts
21
+ from .runner import RunResult, done_ids, iter_jsonl, run_parallel
22
+ from .score import message_text, score
23
+
24
+ EFFORTS = ("auto", "default", "none", "minimal", "low", "medium", "high", "xhigh", "max")
25
+ # Request fields carried over from the recorded request.
26
+ _CARRY = ("tools", "tool_choice", "parallel_tool_calls", "response_format")
27
+
28
+
29
+ class CompareError(ValueError):
30
+ pass
31
+
32
+
33
+ # Chat Completions rules for OpenAI reasoning models (checked 2026-11-07):
34
+ # https://developers.openai.com/api/docs/guides/latest-model
35
+ # https://developers.openai.com/api/docs/models/gpt-6-luna
36
+ # - GPT-6 Luna/Sol/Terra and GPT-5.1+ accept reasoning_effort "none"; temperature, top_p and
37
+ # function tools work in Chat Completions only at "none".
38
+ # - GPT-6 Astra and GPT-6.1 Sol have no "none" and no function tools in Chat Completions.
39
+ # - o-series: no "none", no temperature.
40
+ def family(model: str) -> str:
41
+ m = model.lower()
42
+ if m.startswith(("gpt-6-astra", "gpt-6.1-sol")):
43
+ return "no-none"
44
+ if re.match(r"^o\d", m):
45
+ return "o-series"
46
+ if m in ("gpt-5", "gpt-5-mini", "gpt-5-nano") or re.match(r"^gpt-5-(mini|nano)?-?\d{4}-", m):
47
+ return "gpt-5"
48
+ if m.startswith(("gpt-6", "gpt-5.")):
49
+ return "none-ok"
50
+ return "plain"
51
+
52
+
53
+ def plan_params(model: str, effort: str, temperature: float | None, has_tools: bool) -> tuple[dict[str, Any], list[str]]:
54
+ """Request parameters for the candidate, and warnings about what was changed."""
55
+ fam = family(model)
56
+ notes: list[str] = []
57
+ if effort == "auto":
58
+ effort = {"none-ok": "none", "no-none": "low", "o-series": "low", "gpt-5": "minimal"}.get(fam, "default")
59
+ params: dict[str, Any] = {}
60
+ if effort != "default":
61
+ params["reasoning_effort"] = effort
62
+ sampling_ok = fam == "plain" or (fam == "none-ok" and effort == "none")
63
+ if temperature is not None:
64
+ if sampling_ok:
65
+ params["temperature"] = temperature
66
+ else:
67
+ notes.append(f"temperature not sent: {model} accepts it only with reasoning_effort none")
68
+ if has_tools:
69
+ if fam == "no-none":
70
+ raise CompareError(f"{model} does not support function tools in Chat Completions.")
71
+ if fam == "none-ok" and effort != "none":
72
+ raise CompareError(
73
+ f"{model} supports function tools in Chat Completions only with --reasoning-effort none."
74
+ )
75
+ return params, notes
76
+
77
+
78
+ @dataclass
79
+ class Shot:
80
+ id: str
81
+ messages: list[dict[str, Any]]
82
+
83
+
84
+ def load_shots(path: Path) -> list[Shot]:
85
+ """Examples from your own training file: single-turn, plain-text answers."""
86
+ shots: list[Shot] = []
87
+ for p in load_prompts(path):
88
+ ref = p.reference
89
+ if not isinstance(ref, dict) or ref.get("tool_calls") or not message_text(ref):
90
+ continue
91
+ turns = [m for m in p.messages if m.get("role") not in ("system", "developer")]
92
+ if len(turns) != 1 or turns[0].get("role") != "user" or p.extra.get("tools"):
93
+ continue
94
+ shots.append(Shot(p.id, [turns[0], {"role": "assistant", "content": message_text(ref)}]))
95
+ return shots
96
+
97
+
98
+ def with_shots(messages: list[dict[str, Any]], shots: list[Shot], n: int, exclude: str) -> tuple[list[dict[str, Any]], int]:
99
+ chosen = [s for s in shots if s.id != exclude][:n]
100
+ if not chosen:
101
+ return messages, 0
102
+ head = [m for m in messages[:1] if m.get("role") in ("system", "developer")]
103
+ examples = [m for s in chosen for m in s.messages]
104
+ return head + examples + messages[len(head):], len(chosen)
105
+
106
+
107
+ def load_golden(path: Path) -> list[dict[str, Any]]:
108
+ rows, seen = [], set()
109
+ for _, row in iter_jsonl(path):
110
+ if row.get("id") and row["id"] not in seen and isinstance(row.get("request"), dict):
111
+ seen.add(row["id"])
112
+ rows.append(row)
113
+ if not rows:
114
+ raise CompareError(f"{path}: no recorded rows. Run `tunekeep record` first.")
115
+ rows.sort(key=lambda r: r.get("line") if isinstance(r.get("line"), int) else 0) # file order, not finish order
116
+ return rows
117
+
118
+
119
+ def run(
120
+ client: Client,
121
+ golden: list[dict[str, Any]],
122
+ out: Path,
123
+ *,
124
+ candidate: str,
125
+ label: str,
126
+ effort: str = "auto",
127
+ temperature: float | None = 0.0,
128
+ max_tokens: int | None = None,
129
+ shots: list[Shot] | None = None,
130
+ n_shots: int = 0,
131
+ extra_body: dict[str, Any] | None = None,
132
+ workers: int = 4,
133
+ progress=None,
134
+ ) -> tuple[RunResult, list[str]]:
135
+ has_tools = any(row["request"].get("tools") for row in golden)
136
+ params, notes = plan_params(candidate, effort, temperature, has_tools)
137
+ done = done_ids(out, "candidate", label)
138
+ todo = [row for row in golden if row["id"] not in done]
139
+
140
+ def one(row: dict[str, Any]) -> dict[str, Any]:
141
+ req = row["request"]
142
+ messages, used = with_shots(req.get("messages") or [], shots or [], n_shots, row["id"])
143
+ body: dict[str, Any] = {"model": candidate, "messages": messages, **params}
144
+ body.update({k: req[k] for k in _CARRY if k in req})
145
+ if max_tokens is not None:
146
+ body["max_completion_tokens"] = max_tokens
147
+ body.update(extra_body or {})
148
+ start = time.monotonic()
149
+ resp = client.chat(body)
150
+ latency = round((time.monotonic() - start) * 1000)
151
+ choice = (resp.get("choices") or [{}])[0]
152
+ message = choice.get("message") or {}
153
+ ref = row.get("reference")
154
+ return {
155
+ "id": row["id"],
156
+ "line": row.get("line"),
157
+ "candidate": label,
158
+ "model": candidate,
159
+ "golden_model": row.get("model"),
160
+ "shots": used,
161
+ "params": {k: v for k, v in body.items() if k not in ("model", "messages", "tools")},
162
+ "response": {"message": message, "finish_reason": choice.get("finish_reason")},
163
+ "scores": {
164
+ "golden": score((row.get("response") or {}).get("message"), message),
165
+ "reference": score(ref, message) if isinstance(ref, dict) else None,
166
+ },
167
+ "usage": resp.get("usage"),
168
+ "latency_ms": latency,
169
+ "compared_at": dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds"),
170
+ }
171
+
172
+ result = run_parallel(todo, one, out, total=len(golden), workers=workers, progress=progress)
173
+ return result, notes
174
+
175
+
176
+ # -- summaries ---------------------------------------------------------------------
177
+
178
+
179
+ def _pct(values: list[float], q: float) -> float | None:
180
+ if not values:
181
+ return None
182
+ values = sorted(values)
183
+ k = (len(values) - 1) * q
184
+ lo, hi = int(k), min(int(k) + 1, len(values) - 1)
185
+ return values[lo] + (values[hi] - values[lo]) * (k - lo)
186
+
187
+
188
+ def summarize_scores(scores: list[dict[str, Any]]) -> dict[str, Any]:
189
+ scores = [s for s in scores if s]
190
+ if not scores:
191
+ return {"n": 0}
192
+ kinds: dict[str, int] = {}
193
+ for s in scores:
194
+ kinds[s["kind"]] = kinds.get(s["kind"], 0) + 1
195
+ json_rows = [s for s in scores if s["kind"] == "json"]
196
+ return {
197
+ "n": len(scores),
198
+ "mean": round(statistics.fmean(s["score"] for s in scores), 4),
199
+ "exact": round(sum(1 for s in scores if s.get("exact")) / len(scores), 4),
200
+ "match": round(sum(1 for s in scores if s.get("exact") or s.get("normalized") or s["score"] >= 0.999)
201
+ / len(scores), 4),
202
+ "json_valid": round(sum(1 for s in json_rows if s.get("json_valid")) / len(json_rows), 4) if json_rows else None,
203
+ "kinds": kinds,
204
+ }
205
+
206
+
207
+ def summarize(rows: list[dict[str, Any]]) -> dict[str, Any]:
208
+ lat = [r["latency_ms"] for r in rows if isinstance(r.get("latency_ms"), (int, float))]
209
+ usage = [r.get("usage") or {} for r in rows]
210
+ details = [u.get("completion_tokens_details") or {} for u in usage]
211
+ return {
212
+ "candidate": rows[0]["candidate"] if rows else None,
213
+ "model": rows[0].get("model") if rows else None,
214
+ "rows": len(rows),
215
+ "vs_golden": summarize_scores([(r.get("scores") or {}).get("golden") for r in rows]),
216
+ "vs_reference": summarize_scores([(r.get("scores") or {}).get("reference") for r in rows]),
217
+ "latency_p50_ms": _round(_pct(lat, 0.5)),
218
+ "latency_p95_ms": _round(_pct(lat, 0.95)),
219
+ "prompt_tokens": sum(u.get("prompt_tokens") or 0 for u in usage),
220
+ "completion_tokens": sum(u.get("completion_tokens") or 0 for u in usage),
221
+ "reasoning_tokens": sum(d.get("reasoning_tokens") or 0 for d in details),
222
+ }
223
+
224
+
225
+ def golden_baseline(golden: list[dict[str, Any]]) -> dict[str, Any] | None:
226
+ """How closely the fine-tune itself matched its training answers (no API calls)."""
227
+ scored = [score(r["reference"], (r.get("response") or {}).get("message"))
228
+ for r in golden if isinstance(r.get("reference"), dict)]
229
+ if not scored:
230
+ return None
231
+ lat = [r["latency_ms"] for r in golden if isinstance(r.get("latency_ms"), (int, float))]
232
+ usage = [r.get("usage") or {} for r in golden]
233
+ return {
234
+ "candidate": f"{golden[0].get('model')} (recorded)",
235
+ "model": golden[0].get("model"),
236
+ "rows": len(golden),
237
+ "vs_golden": {"n": 0},
238
+ "vs_reference": summarize_scores(scored),
239
+ "latency_p50_ms": _round(_pct(lat, 0.5)),
240
+ "latency_p95_ms": _round(_pct(lat, 0.95)),
241
+ "prompt_tokens": sum(u.get("prompt_tokens") or 0 for u in usage),
242
+ "completion_tokens": sum(u.get("completion_tokens") or 0 for u in usage),
243
+ "reasoning_tokens": 0,
244
+ }
245
+
246
+
247
+ def _round(x: float | None) -> int | None:
248
+ return None if x is None else round(x)
249
+
250
+
251
+ def load_rows(path: Path) -> dict[str, list[dict[str, Any]]]:
252
+ """Compare rows grouped by candidate label (a file can hold several runs)."""
253
+ groups: dict[str, list[dict[str, Any]]] = {}
254
+ for _, row in iter_jsonl(path):
255
+ if row.get("candidate") and row.get("scores"):
256
+ groups.setdefault(row["candidate"], []).append(row)
257
+ return groups
258
+
259
+
260
+ def render_summary(s: dict[str, Any]) -> str:
261
+ def pct(x: float | None) -> str:
262
+ return "-" if x is None else f"{x * 100:.1f}%"
263
+
264
+ g, r = s["vs_golden"], s["vs_reference"]
265
+ lines = [f"{s['candidate']}: {s['rows']} prompts"]
266
+ if g.get("n"):
267
+ lines.append(f" vs recorded fine-tune: score {pct(g['mean'])}, same answer {pct(g['match'])}, exact {pct(g['exact'])}"
268
+ + (f", valid JSON {pct(g['json_valid'])}" if g.get("json_valid") is not None else ""))
269
+ if r.get("n"):
270
+ lines.append(f" vs training answers: score {pct(r['mean'])}, same answer {pct(r['match'])}")
271
+ if s["latency_p50_ms"] is not None:
272
+ lines.append(f" latency p50 {s['latency_p50_ms']} ms, p95 {s['latency_p95_ms']} ms; "
273
+ f"tokens in {s['prompt_tokens']:,}, out {s['completion_tokens']:,}"
274
+ + (f" (reasoning {s['reasoning_tokens']:,})" if s["reasoning_tokens"] else ""))
275
+ return "\n".join(lines)
276
+
tunekeep/files.py ADDED
@@ -0,0 +1,42 @@
1
+ """File helpers. Outputs can hold production prompts, so they are private by default."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ from pathlib import Path
8
+
9
+
10
+ def ensure_private_dir(path: Path) -> None:
11
+ path.mkdir(parents=True, exist_ok=True)
12
+ try:
13
+ os.chmod(path, 0o700)
14
+ except OSError:
15
+ pass
16
+
17
+
18
+ def write_private(path: Path, data: bytes) -> None:
19
+ """Write a file readable only by the current user (atomic replace)."""
20
+ ensure_private_dir(path.parent)
21
+ tmp = path.with_name(f".{path.name}.tmp")
22
+ fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
23
+ try:
24
+ with os.fdopen(fd, "wb") as fh:
25
+ fh.write(data)
26
+ except BaseException:
27
+ tmp.unlink(missing_ok=True)
28
+ raise
29
+ os.replace(tmp, path)
30
+
31
+
32
+ def open_private_append(path: Path):
33
+ ensure_private_dir(path.parent)
34
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
35
+ return os.fdopen(fd, "a", encoding="utf-8")
36
+
37
+
38
+ def safe_name(name: str) -> str:
39
+ """A filename that cannot escape its directory."""
40
+ base = os.path.basename(name.replace("\\", "/"))
41
+ base = re.sub(r"[^A-Za-z0-9._-]+", "_", base).strip("._")
42
+ return base[:120] or "file"
tunekeep/inventory.py ADDED
@@ -0,0 +1,173 @@
1
+ """`tunekeep inventory`: everything an account has fine-tuned, and when it dies."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import datetime as dt
6
+ import json
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from . import __version__
11
+ from .client import APIError, Client
12
+ from .files import safe_name, write_private
13
+ from .schedule import DEPRECATIONS_URL, REPLACEMENT, TRAINING_ENDS, base_of, days_left, shutdown_for
14
+
15
+
16
+ def collect(client: Client, out_dir: Path, download: bool = True, today: dt.date | None = None) -> dict[str, Any]:
17
+ notes: list[str] = []
18
+ models = client.list_models()
19
+ listed = {m.get("id"): m for m in models if m.get("id")}
20
+
21
+ try:
22
+ jobs = client.list_jobs()
23
+ except APIError as e:
24
+ jobs = []
25
+ notes.append(f"Could not list fine-tuning jobs ({e}). Only models listed by /v1/models are shown.")
26
+
27
+ by_model: dict[str, dict[str, Any]] = {}
28
+
29
+ def entry(model_id: str) -> dict[str, Any]:
30
+ if model_id not in by_model:
31
+ api_value = (listed.get(model_id) or {}).get("shutdown_date")
32
+ date, source = shutdown_for(model_id, api_value)
33
+ by_model[model_id] = {
34
+ "id": model_id,
35
+ "base": base_of(model_id),
36
+ "listed": model_id in listed,
37
+ "shutdown_date": date.isoformat() if date else None,
38
+ "shutdown_source": source,
39
+ "days_left": days_left(date, today),
40
+ "job": None,
41
+ # Checkpoint ids end in ":ckpt-step-N"; this holds even when jobs can't be listed.
42
+ "checkpoint_of": model_id.split(":ckpt-step-")[0] if ":ckpt-step-" in model_id else None,
43
+ }
44
+ return by_model[model_id]
45
+
46
+ for model_id in listed:
47
+ if model_id.startswith("ft:"):
48
+ entry(model_id)
49
+
50
+ files: dict[str, dict[str, Any]] = {}
51
+ job_rows: list[dict[str, Any]] = []
52
+ for job in jobs:
53
+ job_id = job.get("id")
54
+ ft_model = job.get("fine_tuned_model")
55
+ row = {
56
+ "id": job_id,
57
+ "status": job.get("status"),
58
+ "base": job.get("model"),
59
+ "fine_tuned_model": ft_model,
60
+ "method": (job.get("method") or {}).get("type") or "supervised",
61
+ "created_at": job.get("created_at"),
62
+ "training_file": job.get("training_file"),
63
+ "validation_file": job.get("validation_file"),
64
+ "result_files": job.get("result_files") or [],
65
+ "hyperparameters": job.get("hyperparameters"),
66
+ "trained_tokens": job.get("trained_tokens"),
67
+ "seed": job.get("seed"),
68
+ "suffix": job.get("suffix"),
69
+ "checkpoints": [],
70
+ }
71
+ if ft_model:
72
+ entry(ft_model)["job"] = job_id
73
+ try:
74
+ for ck in client.list_checkpoints(job_id):
75
+ name = ck.get("fine_tuned_model_checkpoint")
76
+ row["checkpoints"].append(
77
+ {"id": ck.get("id"), "model": name, "step": ck.get("step_number"), "metrics": ck.get("metrics")}
78
+ )
79
+ if name:
80
+ e = entry(name)
81
+ e["job"] = job_id
82
+ e["checkpoint_of"] = ft_model
83
+ except APIError as e:
84
+ notes.append(f"Could not list checkpoints for {job_id} ({e}).")
85
+ job_rows.append(row)
86
+
87
+ roles = [("training", row["training_file"]), ("validation", row["validation_file"])]
88
+ roles += [("result", f) for f in row["result_files"]]
89
+ for role, file_id in roles:
90
+ if file_id and file_id not in files:
91
+ files[file_id] = _file_record(client, file_id, role, job_id, out_dir, download)
92
+
93
+ inventory = {
94
+ "tool": f"tunekeep {__version__}",
95
+ "generated_at": dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds"),
96
+ "base_url": client.base_url,
97
+ "training_ends": TRAINING_ENDS.isoformat(),
98
+ "replacement": REPLACEMENT,
99
+ "sources": [DEPRECATIONS_URL],
100
+ "models": sorted(by_model.values(), key=lambda m: (m["checkpoint_of"] is not None, m["id"])),
101
+ "jobs": job_rows,
102
+ "files": list(files.values()),
103
+ "notes": notes,
104
+ }
105
+ write_private(out_dir / "inventory.json", json.dumps(inventory, indent=2).encode("utf-8"))
106
+ return inventory
107
+
108
+
109
+ def _file_record(client: Client, file_id: str, role: str, job_id: str, out_dir: Path, download: bool) -> dict[str, Any]:
110
+ rec: dict[str, Any] = {"id": file_id, "role": role, "job": job_id, "filename": None, "bytes": None,
111
+ "purpose": None, "saved_to": None, "download": "skipped"}
112
+ try:
113
+ meta = client.get_file(file_id)
114
+ rec.update(filename=meta.get("filename"), bytes=meta.get("bytes"), purpose=meta.get("purpose"))
115
+ except APIError as e:
116
+ rec["download"] = f"metadata unavailable: {e.message}"
117
+ return rec
118
+ if not download:
119
+ return rec
120
+ try:
121
+ data = client.file_content(file_id)
122
+ except APIError as e:
123
+ # OpenAI does not return files uploaded with purpose=fine-tune.
124
+ rec["download"] = f"blocked by the API ({e.status}): {e.message}"
125
+ return rec
126
+ target = out_dir / "files" / f"{file_id}-{safe_name(rec['filename'] or 'file')}"
127
+ write_private(target, data)
128
+ rec["saved_to"] = str(target)
129
+ rec["download"] = "saved"
130
+ return rec
131
+
132
+
133
+ def render(inv: dict[str, Any]) -> str:
134
+ """Plain-text summary for the terminal."""
135
+ lines: list[str] = []
136
+ today = dt.datetime.now(dt.timezone.utc).date()
137
+ left_training = (dt.date.fromisoformat(inv["training_ends"]) - today).days
138
+ models = [m for m in inv["models"] if not m["checkpoint_of"]]
139
+ ckpts = [m for m in inv["models"] if m["checkpoint_of"]]
140
+
141
+ ck = f" (plus {len(ckpts)} checkpoint{'s' if len(ckpts) != 1 else ''})" if ckpts else ""
142
+ lines.append(f"Fine-tuned models: {len(models)}{ck}")
143
+ if left_training >= 0:
144
+ lines.append(f"New fine-tuning jobs stop on {inv['training_ends']} ({left_training} days).")
145
+ lines.append("")
146
+ if models:
147
+ w = max(len(m["id"]) for m in models)
148
+ lines.append(f"{'MODEL'.ljust(w)} SHUTS DOWN DAYS LEFT SOURCE")
149
+ for m in models:
150
+ d = m["days_left"]
151
+ left = "unknown" if d is None else ("gone" if d < 0 else str(d))
152
+ lines.append(f"{m['id'].ljust(w)} {(m['shutdown_date'] or '-').ljust(11)} {left.ljust(9)} {m['shutdown_source']}")
153
+ else:
154
+ lines.append("No fine-tuned models found for this key.")
155
+
156
+ blocked = [f for f in inv["files"] if f["role"] in ("training", "validation") and f["download"].startswith("blocked")]
157
+ saved = [f for f in inv["files"] if f["download"] == "saved"]
158
+ lines.append("")
159
+ noun = "file" if len(blocked) == 1 else "files"
160
+ lines.append(f"Files: {len(saved)} saved, {len(blocked)} training/validation {noun} the API would not return.")
161
+ if blocked:
162
+ lines.append(" Keep your own copy of these; tunekeep retrains only from your original data:")
163
+ for f in blocked:
164
+ lines.append(f" - {f['id']} {f['filename'] or ''} (job {f['job']})")
165
+ for note in inv["notes"]:
166
+ lines.append(f"Note: {note}")
167
+
168
+ alive = [m for m in models if m["days_left"] is not None and m["days_left"] >= 0]
169
+ if alive:
170
+ lines.append("")
171
+ lines.append("Next: record how each model behaves while it still answers, e.g.")
172
+ lines.append(f" tunekeep record --model {alive[0]['id']} --prompts prompts.jsonl")
173
+ return "\n".join(lines)
tunekeep/kit/README.md ADDED
@@ -0,0 +1,47 @@
1
+ # Retraining kit (written by tunekeep)
2
+
3
+ This folder retrains an open model with LoRA on **your own training data** and
4
+ exports it for local serving.
5
+
6
+ | File | What it is |
7
+ |---|---|
8
+ | `data/train.jsonl`, `data/validation.jsonl` | Your data, converted to plain chat format. Private (0600); not committed (`.gitignore`). |
9
+ | `config.json` | Base model (pinned revision), epochs, LoRA settings, GPU, time limit. Edit freely. |
10
+ | `train_lora.py` | The training script. Loss only on assistant messages; `"weight": 0` turns are skipped. |
11
+ | `modal_app.py` | Runs the script on a Modal GPU, then converts to GGUF on CPU. |
12
+ | `requirements-train.txt`, `requirements-gguf.txt` | Exact versions, pinned by hash. |
13
+
14
+ ## Run on Modal
15
+
16
+ ```sh
17
+ pip install modal && modal setup # once
18
+ modal run modal_app.py --max-steps 5 # smoke test: a few minutes, cents
19
+ modal run modal_app.py # the real run
20
+ ```
21
+
22
+ - Your `data/` is uploaded to **your** Modal account for the run.
23
+ - The job stops after `timeout_hours` (config.json), which caps what a run can cost.
24
+ Set a workspace budget in Modal's settings as well.
25
+ - Outputs land in the Modal Volume `tunekeep` under `runs/<name>/`: `adapter/` (LoRA),
26
+ `merged/` (full weights), `model-q8_0.gguf`, `metrics.jsonl`, `summary.json`.
27
+ The run prints the `modal volume get` commands to download them.
28
+
29
+ On any other CUDA machine: `pip install -r requirements-train.txt`, then
30
+ `python train_lora.py --config config.json --data data --out out`.
31
+
32
+ ## Serve and compare
33
+
34
+ ```sh
35
+ llama-server -m model-q8_0.gguf --jinja --port 8080
36
+ tunekeep compare --golden tunekeep-out/golden-<model>.jsonl \
37
+ --candidate retrained --candidate-base-url http://127.0.0.1:8080/v1 \
38
+ --extra-body '{"chat_template_kwargs": {"enable_thinking": false}}'
39
+ tunekeep report --retrain-summary runs/<name>/summary.json
40
+ ```
41
+
42
+ The model was trained without thinking blocks (`enable_thinking: false` for Qwen3), so serve it the same way.
43
+
44
+ ## Licences
45
+
46
+ The base model keeps its own licence (Qwen3: Apache-2.0). Your data is yours. Check that
47
+ your training data is not itself the output of a provider whose terms forbid training on it.
@@ -0,0 +1,98 @@
1
+ """Run the LoRA retrain on Modal (https://modal.com). Written by tunekeep.
2
+
3
+ modal run modal_app.py # train on a GPU, then convert to GGUF on CPU
4
+ modal run modal_app.py --no-gguf # skip the GGUF step
5
+ modal run modal_app.py --max-steps 5 # a cheap smoke test first
6
+
7
+ Your training data (data/) is uploaded to your own Modal account for the run.
8
+ Outputs (LoRA adapter, merged weights, GGUF, metrics) go to the Modal Volume
9
+ "tunekeep" under runs/<name>/. The job stops after `timeout_hours`, which caps
10
+ the cost of a run.
11
+
12
+ Packages are pinned with hashes (requirements-*.txt) and llama.cpp is pinned
13
+ to an exact commit.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import time
20
+ from pathlib import Path
21
+
22
+ import modal
23
+
24
+ HERE = Path(__file__).parent
25
+ KIT = "/root/kit"
26
+ CONFIG = json.loads((HERE / "config.json" if (HERE / "config.json").exists() else Path(KIT) / "config.json").read_text())
27
+
28
+ LLAMA_CPP_TAG = "b11980"
29
+ LLAMA_CPP_COMMIT = "f7c01c5226cac1de1052c17920ff38cdefea0ae6"
30
+
31
+ volume = modal.Volume.from_name("tunekeep", create_if_missing=True)
32
+ app = modal.App("tunekeep-retrain")
33
+
34
+
35
+ # Image definitions are evaluated lazily, so the local paths below are only read on your machine.
36
+ train_image = (
37
+ modal.Image.debian_slim(python_version="3.12")
38
+ .pip_install_from_requirements(str(HERE / "requirements-train.txt"))
39
+ .env({"HF_HOME": "/vol/hf", "TOKENIZERS_PARALLELISM": "false"})
40
+ .add_local_file(HERE / "config.json", f"{KIT}/config.json")
41
+ .add_local_file(HERE / "train_lora.py", f"{KIT}/train_lora.py")
42
+ .add_local_dir(HERE / "data", f"{KIT}/data")
43
+ )
44
+
45
+ gguf_image = (
46
+ modal.Image.debian_slim(python_version="3.12")
47
+ .apt_install("git")
48
+ .run_commands(
49
+ f"git clone --depth 1 --branch {LLAMA_CPP_TAG} https://github.com/ggml-org/llama.cpp /opt/llama.cpp",
50
+ f'test "$(git -C /opt/llama.cpp rev-parse HEAD)" = "{LLAMA_CPP_COMMIT}"',
51
+ )
52
+ .pip_install_from_requirements(str(HERE / "requirements-gguf.txt"))
53
+ )
54
+
55
+
56
+ @app.function(image=train_image, gpu=CONFIG.get("gpu", "L40S"), volumes={"/vol": volume},
57
+ timeout=int(float(CONFIG.get("timeout_hours", 2)) * 3600))
58
+ def train(run: str, max_steps: int = 0) -> dict:
59
+ import subprocess
60
+
61
+ out = f"/vol/runs/{run}"
62
+ cmd = ["python", f"{KIT}/train_lora.py", "--config", f"{KIT}/config.json", "--data", f"{KIT}/data", "--out", out]
63
+ if max_steps:
64
+ cmd += ["--max-steps", str(max_steps)]
65
+ subprocess.run(cmd, check=True)
66
+ volume.commit()
67
+ return json.loads(Path(out, "summary.json").read_text())
68
+
69
+
70
+ @app.function(image=gguf_image, cpu=4, memory=32768, volumes={"/vol": volume}, timeout=2 * 3600)
71
+ def to_gguf(run: str, outtype: str) -> str:
72
+ import subprocess
73
+
74
+ volume.reload()
75
+ out = Path(f"/vol/runs/{run}")
76
+ target = out / f"model-{outtype}.gguf"
77
+ subprocess.run(["python", "/opt/llama.cpp/convert_hf_to_gguf.py", str(out / "merged"),
78
+ "--outfile", str(target), "--outtype", outtype], check=True)
79
+ volume.commit()
80
+ return str(target.relative_to("/vol"))
81
+
82
+
83
+ @app.local_entrypoint()
84
+ def main(run: str = "", max_steps: int = 0, no_gguf: bool = False) -> None:
85
+ run = run or time.strftime("%Y%m%d-%H%M%S")
86
+ print(f"run {run}: training {CONFIG['base']} on {CONFIG.get('gpu', 'L40S')}")
87
+ summary = train.remote(run, max_steps)
88
+ local = HERE / "runs" / run
89
+ local.mkdir(parents=True, exist_ok=True)
90
+ (local / "summary.json").write_text(json.dumps(summary, indent=2))
91
+ print(json.dumps({k: summary[k] for k in ("train_samples", "steps", "train_seconds", "validation_loss_before",
92
+ "validation_loss_by_epoch")}, indent=2))
93
+ files = [f"runs/{run}/adapter/", f"runs/{run}/merged/"]
94
+ if not no_gguf and CONFIG.get("gguf"):
95
+ files.append(to_gguf.remote(run, CONFIG["gguf"]))
96
+ print(f"\nSummary saved to {local / 'summary.json'}. Download with:")
97
+ for f in files:
98
+ print(f" modal volume get tunekeep {f} {local}/")