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/__init__.py +3 -0
- tunekeep/__main__.py +3 -0
- tunekeep/cli.py +301 -0
- tunekeep/client.py +189 -0
- tunekeep/compare.py +276 -0
- tunekeep/files.py +42 -0
- tunekeep/inventory.py +173 -0
- tunekeep/kit/README.md +47 -0
- tunekeep/kit/modal_app.py +98 -0
- tunekeep/kit/requirements-gguf.txt +845 -0
- tunekeep/kit/requirements-train.txt +830 -0
- tunekeep/kit/train_lora.py +282 -0
- tunekeep/record.py +150 -0
- tunekeep/report.py +259 -0
- tunekeep/retrain.py +262 -0
- tunekeep/runner.py +91 -0
- tunekeep/schedule.py +74 -0
- tunekeep/score.py +166 -0
- tunekeep-0.2.1.dist-info/METADATA +185 -0
- tunekeep-0.2.1.dist-info/RECORD +23 -0
- tunekeep-0.2.1.dist-info/WHEEL +4 -0
- tunekeep-0.2.1.dist-info/entry_points.txt +3 -0
- tunekeep-0.2.1.dist-info/licenses/LICENSE +202 -0
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}/")
|