overturn-trainer 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.
- overturn_trainer-0.1.0/.gitignore +3 -0
- overturn_trainer-0.1.0/PKG-INFO +38 -0
- overturn_trainer-0.1.0/README.md +15 -0
- overturn_trainer-0.1.0/overturn_trainer/__init__.py +7 -0
- overturn_trainer-0.1.0/overturn_trainer/api.py +51 -0
- overturn_trainer-0.1.0/overturn_trainer/cli.py +116 -0
- overturn_trainer-0.1.0/overturn_trainer/py.typed +0 -0
- overturn_trainer-0.1.0/overturn_trainer/train.py +69 -0
- overturn_trainer-0.1.0/overturn_trainer/trl_backend.py +88 -0
- overturn_trainer-0.1.0/package.json +12 -0
- overturn_trainer-0.1.0/pyproject.toml +47 -0
- overturn_trainer-0.1.0/tests/test_cli.py +155 -0
- overturn_trainer-0.1.0/uv.lock +3397 -0
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: overturn-trainer
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Train an open-weight model on your Overturn corrections in your own cloud, then register it for the gate
|
|
5
|
+
Project-URL: Homepage, https://github.com/overrule-ai/overturn
|
|
6
|
+
Project-URL: Documentation, https://github.com/overrule-ai/overturn/blob/main/docs/owned-models.md
|
|
7
|
+
Author: Overturn contributors
|
|
8
|
+
License-Expression: Apache-2.0
|
|
9
|
+
Keywords: agents,fine-tuning,llm,lora,overturn
|
|
10
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Typing :: Typed
|
|
13
|
+
Requires-Python: >=3.10
|
|
14
|
+
Requires-Dist: httpx>=0.27
|
|
15
|
+
Provides-Extra: train
|
|
16
|
+
Requires-Dist: accelerate>=0.33; extra == 'train'
|
|
17
|
+
Requires-Dist: datasets>=2.20; extra == 'train'
|
|
18
|
+
Requires-Dist: peft>=0.12; extra == 'train'
|
|
19
|
+
Requires-Dist: torch>=2.3; extra == 'train'
|
|
20
|
+
Requires-Dist: transformers>=4.44; extra == 'train'
|
|
21
|
+
Requires-Dist: trl>=0.12; extra == 'train'
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# overturn-trainer
|
|
25
|
+
|
|
26
|
+
Train an open-weight model on your [Overturn](https://overturn.run) corrections **in your own cloud**, then register it so Overturn gates it against your suite, shadows it on live traffic, and lets a human promote it.
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pip install 'overturn-trainer[train]'
|
|
30
|
+
export OVERTURN_API_KEY=ov_live_…
|
|
31
|
+
|
|
32
|
+
overturn-trainer run --base meta-llama/Llama-3.1-8B-Instruct --agent ticket-triage --out ./triage-lora
|
|
33
|
+
# serve ./triage-lora/adapter on vLLM (--enable-lora --lora-modules triage-lora=./triage-lora/adapter)
|
|
34
|
+
# point the Overturn API at it: LOCAL_MODEL_BASE_URL=http://vllm:8000/v1
|
|
35
|
+
overturn-trainer run … --register local/triage-lora # or: overturn models register --base … --served-as local/triage-lora
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Nothing but the export leaves Overturn; nothing but the registration goes back. `--backend dry` runs the whole pipeline without the ML stack (export, dataset, manifest, registration) — what CI tests.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# overturn-trainer
|
|
2
|
+
|
|
3
|
+
Train an open-weight model on your [Overturn](https://overturn.run) corrections **in your own cloud**, then register it so Overturn gates it against your suite, shadows it on live traffic, and lets a human promote it.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pip install 'overturn-trainer[train]'
|
|
7
|
+
export OVERTURN_API_KEY=ov_live_…
|
|
8
|
+
|
|
9
|
+
overturn-trainer run --base meta-llama/Llama-3.1-8B-Instruct --agent ticket-triage --out ./triage-lora
|
|
10
|
+
# serve ./triage-lora/adapter on vLLM (--enable-lora --lora-modules triage-lora=./triage-lora/adapter)
|
|
11
|
+
# point the Overturn API at it: LOCAL_MODEL_BASE_URL=http://vllm:8000/v1
|
|
12
|
+
overturn-trainer run … --register local/triage-lora # or: overturn models register --base … --served-as local/triage-lora
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Nothing but the export leaves Overturn; nothing but the registration goes back. `--backend dry` runs the whole pipeline without the ML stack (export, dataset, manifest, registration) — what CI tests.
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""The two Overturn calls the trainer needs: pull the export, register the result."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class OverturnApi:
|
|
13
|
+
def __init__(self, api_key: str | None = None, base_url: str | None = None) -> None:
|
|
14
|
+
self.api_key = api_key or os.environ.get("OVERTURN_API_KEY") or ""
|
|
15
|
+
self.base_url = (
|
|
16
|
+
base_url or os.environ.get("OVERTURN_BASE_URL") or "https://api.overturn.run"
|
|
17
|
+
).rstrip("/")
|
|
18
|
+
if not self.api_key:
|
|
19
|
+
raise SystemExit("OVERTURN_API_KEY is not set (Settings → API keys)")
|
|
20
|
+
self._client = httpx.Client(timeout=120)
|
|
21
|
+
|
|
22
|
+
def _headers(self) -> dict[str, str]:
|
|
23
|
+
return {"authorization": f"Bearer {self.api_key}", "user-agent": "overturn-trainer/0.1.0"}
|
|
24
|
+
|
|
25
|
+
def export(self, fmt: str, **params: Any) -> list[dict[str, Any]]:
|
|
26
|
+
"""`GET /v1/export?format=sft|dpo` as rows (one JSON object per JSONL line)."""
|
|
27
|
+
res = self._client.get(
|
|
28
|
+
f"{self.base_url}/v1/export",
|
|
29
|
+
params={"format": fmt, **{k: v for k, v in params.items() if v is not None}},
|
|
30
|
+
headers=self._headers(),
|
|
31
|
+
)
|
|
32
|
+
if not res.is_success:
|
|
33
|
+
raise SystemExit(f"export failed: {res.status_code} {res.text[:200]}")
|
|
34
|
+
return [json.loads(line) for line in res.text.splitlines() if line.strip()]
|
|
35
|
+
|
|
36
|
+
def export_stats(self, fmt: str, **params: Any) -> dict[str, Any]:
|
|
37
|
+
res = self._client.get(
|
|
38
|
+
f"{self.base_url}/v1/export/stats",
|
|
39
|
+
params={"format": fmt, **{k: v for k, v in params.items() if v is not None}},
|
|
40
|
+
headers=self._headers(),
|
|
41
|
+
)
|
|
42
|
+
if not res.is_success:
|
|
43
|
+
raise SystemExit(f"export stats failed: {res.status_code} {res.text[:200]}")
|
|
44
|
+
return res.json()
|
|
45
|
+
|
|
46
|
+
def register(self, body: dict[str, Any]) -> dict[str, Any]:
|
|
47
|
+
"""`POST /v1/models/register`: the gate replays the suite against `servedAs` at once."""
|
|
48
|
+
res = self._client.post(f"{self.base_url}/v1/models/register", json=body, headers=self._headers())
|
|
49
|
+
if not res.is_success:
|
|
50
|
+
raise SystemExit(f"register failed: {res.status_code} {res.text[:300]}")
|
|
51
|
+
return res.json()
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""`overturn-trainer run`: export → fine-tune (LoRA) → save → register with Overturn for the gate."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from typing import Any, cast
|
|
9
|
+
|
|
10
|
+
from .api import OverturnApi
|
|
11
|
+
from .train import train
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
15
|
+
p = argparse.ArgumentParser(prog="overturn-trainer", description=__doc__)
|
|
16
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
17
|
+
run = sub.add_parser(
|
|
18
|
+
"run", help="Export, train a LoRA, save it, and (optionally) register it for the gate"
|
|
19
|
+
)
|
|
20
|
+
run.add_argument(
|
|
21
|
+
"--base", required=True, help="Hugging Face model id, e.g. meta-llama/Llama-3.1-8B-Instruct"
|
|
22
|
+
)
|
|
23
|
+
run.add_argument(
|
|
24
|
+
"--out", default="./overturn-model", help="Output directory (adapter/, merged/, manifest)"
|
|
25
|
+
)
|
|
26
|
+
run.add_argument("--method", choices=["sft", "dpo"], default="sft")
|
|
27
|
+
run.add_argument("--epochs", type=int, default=3)
|
|
28
|
+
run.add_argument("--lora-r", type=int, default=16)
|
|
29
|
+
run.add_argument("--learning-rate", type=float, default=2e-4)
|
|
30
|
+
run.add_argument("--max-seq-length", type=int, default=4096)
|
|
31
|
+
run.add_argument("--merge", action="store_true", help="Also save merged full weights (merged/)")
|
|
32
|
+
run.add_argument("--agent", help="Only this agent's cases")
|
|
33
|
+
run.add_argument("--tag", help="Only cases with this tag")
|
|
34
|
+
run.add_argument("--since", help="Cases since (ISO date or 30d)")
|
|
35
|
+
run.add_argument("--min-rows", type=int, default=50, help="Refuse to train on fewer rows than this")
|
|
36
|
+
run.add_argument("--backend", choices=["trl", "dry"], default="trl")
|
|
37
|
+
run.add_argument(
|
|
38
|
+
"--register",
|
|
39
|
+
metavar="PROVIDER/MODEL",
|
|
40
|
+
help="Where the weights will answer from (e.g. local/triage-lora); registers the model so the gate runs",
|
|
41
|
+
)
|
|
42
|
+
run.add_argument("--name", help="Model name in Overturn")
|
|
43
|
+
run.add_argument("--api-key", help="Overturn API key (default: OVERTURN_API_KEY)")
|
|
44
|
+
run.add_argument("--base-url", help="Overturn API URL (default: OVERTURN_BASE_URL or hosted)")
|
|
45
|
+
return p
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def cmd_run(a: argparse.Namespace) -> int:
|
|
49
|
+
api = OverturnApi(a.api_key, a.base_url)
|
|
50
|
+
params: dict[str, Any] = {"agent": a.agent, "tag": a.tag, "since": a.since, "includeApproved": "true"}
|
|
51
|
+
stats = api.export_stats(a.method, **params)
|
|
52
|
+
rows_n = int(stats.get("rows", 0))
|
|
53
|
+
if rows_n < a.min_rows:
|
|
54
|
+
print(
|
|
55
|
+
f"{rows_n} {a.method} rows is under --min-rows {a.min_rows}. Until the corrections pile up, "
|
|
56
|
+
"put them in the prompt instead: overturn replay --examples 5",
|
|
57
|
+
file=sys.stderr,
|
|
58
|
+
)
|
|
59
|
+
return 2
|
|
60
|
+
rows = api.export(a.method, **params)
|
|
61
|
+
print(f"exported {len(rows)} {a.method} rows ({json.dumps(stats.get('byKind', {}))})")
|
|
62
|
+
result = train(
|
|
63
|
+
rows,
|
|
64
|
+
base_model=a.base,
|
|
65
|
+
output_dir=a.out,
|
|
66
|
+
method=a.method,
|
|
67
|
+
epochs=a.epochs,
|
|
68
|
+
lora_r=a.lora_r,
|
|
69
|
+
learning_rate=a.learning_rate,
|
|
70
|
+
max_seq_length=a.max_seq_length,
|
|
71
|
+
merge=a.merge,
|
|
72
|
+
backend=a.backend,
|
|
73
|
+
)
|
|
74
|
+
print(f"trained {result.base_model} ({result.backend}) in {result.seconds:.0f}s → {result.output_dir}")
|
|
75
|
+
if a.register:
|
|
76
|
+
filt: dict[str, Any] = {
|
|
77
|
+
k: v for k, v in {"agent": a.agent, "tag": a.tag, "since": a.since}.items() if v
|
|
78
|
+
}
|
|
79
|
+
m = api.register(
|
|
80
|
+
{
|
|
81
|
+
"name": a.name,
|
|
82
|
+
"baseModel": a.base,
|
|
83
|
+
"servedAs": a.register,
|
|
84
|
+
"method": a.method,
|
|
85
|
+
"epochs": a.epochs,
|
|
86
|
+
"filter": filt,
|
|
87
|
+
"datasetStats": stats,
|
|
88
|
+
}
|
|
89
|
+
)
|
|
90
|
+
gate: dict[str, Any] = m.get("gate") or {}
|
|
91
|
+
raw_reasons: Any = gate.get("reasons") or []
|
|
92
|
+
reasons: list[str] = [str(r) for r in cast(list[Any], raw_reasons)]
|
|
93
|
+
verdict = (
|
|
94
|
+
f", gate {'passed' if gate.get('pass') else 'failed'} ({'; '.join(reasons) or 'no regressions'})"
|
|
95
|
+
if gate
|
|
96
|
+
else ""
|
|
97
|
+
)
|
|
98
|
+
print(f"registered {m.get('name')} as {m.get('servedAs')}: status {m.get('status')}{verdict}")
|
|
99
|
+
return 0 if m.get("status") in ("passed", "trained", "gating") else 1
|
|
100
|
+
print(
|
|
101
|
+
"serve the adapter on any OpenAI-compatible server, then: "
|
|
102
|
+
f"overturn models register --base {a.base} --served-as local/<name>"
|
|
103
|
+
+ (f" --agent {a.agent}" if a.agent else "")
|
|
104
|
+
)
|
|
105
|
+
return 0
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def main(argv: list[str] | None = None) -> int:
|
|
109
|
+
a = build_parser().parse_args(argv)
|
|
110
|
+
if a.cmd == "run":
|
|
111
|
+
return cmd_run(a)
|
|
112
|
+
return 2
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
if __name__ == "__main__":
|
|
116
|
+
raise SystemExit(main())
|
|
File without changes
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""LoRA fine-tuning on Overturn's export. The `trl` backend needs the `[train]` extra and a GPU
|
|
2
|
+
worth the name; the `dry` backend writes the dataset and a manifest and is what the tests run."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import time
|
|
8
|
+
from dataclasses import asdict, dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class TrainResult:
|
|
15
|
+
backend: str
|
|
16
|
+
base_model: str
|
|
17
|
+
method: str
|
|
18
|
+
rows: int
|
|
19
|
+
epochs: int
|
|
20
|
+
lora_r: int
|
|
21
|
+
output_dir: str
|
|
22
|
+
seconds: float
|
|
23
|
+
merged: bool
|
|
24
|
+
|
|
25
|
+
def manifest(self) -> dict[str, Any]:
|
|
26
|
+
return asdict(self)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def write_dataset(rows: list[dict[str, Any]], output_dir: Path, method: str) -> Path:
|
|
30
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
31
|
+
path = output_dir / f"train-{method}.jsonl"
|
|
32
|
+
with path.open("w", encoding="utf-8") as f:
|
|
33
|
+
for r in rows:
|
|
34
|
+
f.write(json.dumps(r, ensure_ascii=False) + "\n")
|
|
35
|
+
return path
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def train(
|
|
39
|
+
rows: list[dict[str, Any]],
|
|
40
|
+
*,
|
|
41
|
+
base_model: str,
|
|
42
|
+
output_dir: str | Path,
|
|
43
|
+
method: str = "sft",
|
|
44
|
+
epochs: int = 3,
|
|
45
|
+
lora_r: int = 16,
|
|
46
|
+
learning_rate: float = 2e-4,
|
|
47
|
+
max_seq_length: int = 4096,
|
|
48
|
+
merge: bool = False,
|
|
49
|
+
backend: str = "trl",
|
|
50
|
+
) -> TrainResult:
|
|
51
|
+
"""Fine-tune `base_model` on `rows` (Overturn's sft/dpo export) and save the adapter to `output_dir`."""
|
|
52
|
+
out = Path(output_dir)
|
|
53
|
+
t0 = time.time()
|
|
54
|
+
dataset = write_dataset(rows, out, method)
|
|
55
|
+
if backend == "dry":
|
|
56
|
+
result = TrainResult(
|
|
57
|
+
"dry", base_model, method, len(rows), epochs, lora_r, str(out), time.time() - t0, False
|
|
58
|
+
)
|
|
59
|
+
elif backend == "trl":
|
|
60
|
+
from .trl_backend import train_trl
|
|
61
|
+
|
|
62
|
+
train_trl(dataset, out, base_model, method, epochs, lora_r, learning_rate, max_seq_length, merge)
|
|
63
|
+
result = TrainResult(
|
|
64
|
+
"trl", base_model, method, len(rows), epochs, lora_r, str(out), time.time() - t0, merge
|
|
65
|
+
)
|
|
66
|
+
else:
|
|
67
|
+
raise SystemExit(f"unknown backend {backend!r} (trl | dry)")
|
|
68
|
+
(out / "overturn-manifest.json").write_text(json.dumps(result.manifest(), indent=2))
|
|
69
|
+
return result
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# pyright: basic
|
|
2
|
+
"""The TRL/PEFT backend: LoRA SFT or DPO on the export. Imported lazily; needs `overturn-trainer[train]`
|
|
3
|
+
and a GPU worth the name. Untyped third-party stack, hence the relaxed checker for this file only."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def train_trl( # pragma: no cover - needs the ML stack and a GPU; exercised by hand, not in CI
|
|
12
|
+
dataset: Path,
|
|
13
|
+
out: Path,
|
|
14
|
+
base_model: str,
|
|
15
|
+
method: str,
|
|
16
|
+
epochs: int,
|
|
17
|
+
lora_r: int,
|
|
18
|
+
learning_rate: float,
|
|
19
|
+
max_seq_length: int,
|
|
20
|
+
merge: bool,
|
|
21
|
+
) -> None:
|
|
22
|
+
try:
|
|
23
|
+
import torch # type: ignore[import-not-found]
|
|
24
|
+
from datasets import load_dataset # type: ignore[import-not-found]
|
|
25
|
+
from peft import LoraConfig # type: ignore[import-not-found]
|
|
26
|
+
from transformers import AutoModelForCausalLM, AutoTokenizer # type: ignore[import-not-found]
|
|
27
|
+
except ImportError as err:
|
|
28
|
+
raise SystemExit("the trl backend needs: pip install 'overturn-trainer[train]'") from err
|
|
29
|
+
|
|
30
|
+
tokenizer: Any = AutoTokenizer.from_pretrained(base_model)
|
|
31
|
+
if tokenizer.pad_token is None:
|
|
32
|
+
tokenizer.pad_token = tokenizer.eos_token
|
|
33
|
+
model: Any = AutoModelForCausalLM.from_pretrained(
|
|
34
|
+
base_model,
|
|
35
|
+
torch_dtype=torch.bfloat16 if torch.cuda.is_available() else torch.float32,
|
|
36
|
+
device_map="auto",
|
|
37
|
+
)
|
|
38
|
+
peft_config: Any = LoraConfig(
|
|
39
|
+
r=lora_r,
|
|
40
|
+
lora_alpha=lora_r * 2,
|
|
41
|
+
lora_dropout=0.05,
|
|
42
|
+
target_modules="all-linear",
|
|
43
|
+
task_type="CAUSAL_LM",
|
|
44
|
+
)
|
|
45
|
+
data: Any = load_dataset("json", data_files=str(dataset), split="train")
|
|
46
|
+
if method == "dpo":
|
|
47
|
+
from trl import DPOConfig, DPOTrainer # type: ignore[import-not-found]
|
|
48
|
+
|
|
49
|
+
trainer: Any = DPOTrainer(
|
|
50
|
+
model=model,
|
|
51
|
+
args=DPOConfig(
|
|
52
|
+
output_dir=str(out / "checkpoints"),
|
|
53
|
+
num_train_epochs=epochs,
|
|
54
|
+
learning_rate=learning_rate,
|
|
55
|
+
beta=0.1,
|
|
56
|
+
logging_steps=10,
|
|
57
|
+
bf16=torch.cuda.is_available(),
|
|
58
|
+
report_to=[],
|
|
59
|
+
),
|
|
60
|
+
train_dataset=data,
|
|
61
|
+
processing_class=tokenizer,
|
|
62
|
+
peft_config=peft_config,
|
|
63
|
+
)
|
|
64
|
+
else:
|
|
65
|
+
from trl import SFTConfig, SFTTrainer # type: ignore[import-not-found]
|
|
66
|
+
|
|
67
|
+
trainer = SFTTrainer(
|
|
68
|
+
model=model,
|
|
69
|
+
args=SFTConfig(
|
|
70
|
+
output_dir=str(out / "checkpoints"),
|
|
71
|
+
num_train_epochs=epochs,
|
|
72
|
+
learning_rate=learning_rate,
|
|
73
|
+
max_length=max_seq_length,
|
|
74
|
+
logging_steps=10,
|
|
75
|
+
bf16=torch.cuda.is_available(),
|
|
76
|
+
report_to=[],
|
|
77
|
+
),
|
|
78
|
+
train_dataset=data,
|
|
79
|
+
processing_class=tokenizer,
|
|
80
|
+
peft_config=peft_config,
|
|
81
|
+
)
|
|
82
|
+
trainer.train()
|
|
83
|
+
trainer.save_model(str(out / "adapter"))
|
|
84
|
+
tokenizer.save_pretrained(str(out / "adapter"))
|
|
85
|
+
if merge:
|
|
86
|
+
merged: Any = trainer.model.merge_and_unload()
|
|
87
|
+
merged.save_pretrained(str(out / "merged"))
|
|
88
|
+
tokenizer.save_pretrained(str(out / "merged"))
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@overturn-ai/trainer",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"description": "Turbo wrapper for the trainer (published to PyPI as `overturn-trainer`)",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"setup": "uv sync --group dev",
|
|
8
|
+
"typecheck": "uv run --no-sync pyright",
|
|
9
|
+
"lint": "uv run --no-sync ruff check . && uv run --no-sync ruff format --check .",
|
|
10
|
+
"test": "uv run --no-sync pytest -q"
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "overturn-trainer"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Train an open-weight model on your Overturn corrections in your own cloud, then register it for the gate"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "Apache-2.0"
|
|
7
|
+
requires-python = ">=3.10"
|
|
8
|
+
authors = [{ name = "Overturn contributors" }]
|
|
9
|
+
dependencies = ["httpx>=0.27"]
|
|
10
|
+
keywords = ["llm", "fine-tuning", "lora", "agents", "overturn"]
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Programming Language :: Python :: 3",
|
|
13
|
+
"License :: OSI Approved :: Apache Software License",
|
|
14
|
+
"Typing :: Typed",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[project.optional-dependencies]
|
|
18
|
+
# the actual training stack; the CLI runs without it in --backend dry (export + manifest + register)
|
|
19
|
+
train = ["torch>=2.3", "transformers>=4.44", "peft>=0.12", "trl>=0.12", "datasets>=2.20", "accelerate>=0.33"]
|
|
20
|
+
|
|
21
|
+
[project.scripts]
|
|
22
|
+
overturn-trainer = "overturn_trainer.cli:main"
|
|
23
|
+
|
|
24
|
+
[project.urls]
|
|
25
|
+
Homepage = "https://github.com/overrule-ai/overturn"
|
|
26
|
+
Documentation = "https://github.com/overrule-ai/overturn/blob/main/docs/owned-models.md"
|
|
27
|
+
|
|
28
|
+
[build-system]
|
|
29
|
+
requires = ["hatchling"]
|
|
30
|
+
build-backend = "hatchling.build"
|
|
31
|
+
|
|
32
|
+
[tool.hatch.build.targets.wheel]
|
|
33
|
+
packages = ["overturn_trainer"]
|
|
34
|
+
|
|
35
|
+
[dependency-groups]
|
|
36
|
+
dev = ["pytest>=8", "pyright>=1.1.400", "ruff>=0.6"]
|
|
37
|
+
|
|
38
|
+
[tool.pyright]
|
|
39
|
+
venvPath = "."
|
|
40
|
+
venv = ".venv"
|
|
41
|
+
include = ["overturn_trainer", "tests"]
|
|
42
|
+
typeCheckingMode = "strict"
|
|
43
|
+
reportMissingImports = false
|
|
44
|
+
|
|
45
|
+
[tool.ruff]
|
|
46
|
+
line-length = 110
|
|
47
|
+
target-version = "py310"
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""The trainer against a fake Overturn API: export in, manifest out, register with the gate's answer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import threading
|
|
7
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
from overturn_trainer.cli import main
|
|
14
|
+
|
|
15
|
+
ROWS = [
|
|
16
|
+
{
|
|
17
|
+
"messages": [
|
|
18
|
+
{"role": "system", "content": "triage"},
|
|
19
|
+
{"role": "user", "content": f"ticket {i}"},
|
|
20
|
+
{"role": "assistant", "content": '{"priority":"P1"}'},
|
|
21
|
+
]
|
|
22
|
+
}
|
|
23
|
+
for i in range(6)
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
CALLS: list[dict[str, Any]] = []
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Handler(BaseHTTPRequestHandler):
|
|
31
|
+
def _json(self, status: int, body: Any) -> None:
|
|
32
|
+
data = json.dumps(body).encode()
|
|
33
|
+
self.send_response(status)
|
|
34
|
+
self.send_header("content-type", "application/json")
|
|
35
|
+
self.send_header("content-length", str(len(data)))
|
|
36
|
+
self.end_headers()
|
|
37
|
+
self.wfile.write(data)
|
|
38
|
+
|
|
39
|
+
def do_GET(self) -> None:
|
|
40
|
+
if self.path.startswith("/v1/export/stats"):
|
|
41
|
+
return self._json(200, {"rows": len(ROWS), "byKind": {"approve": 6}, "format": "sft"})
|
|
42
|
+
if self.path.startswith("/v1/export"):
|
|
43
|
+
CALLS.append({"export": self.path, "auth": self.headers.get("authorization")})
|
|
44
|
+
text = "\n".join(json.dumps(r) for r in ROWS) + "\n"
|
|
45
|
+
data = text.encode()
|
|
46
|
+
self.send_response(200)
|
|
47
|
+
self.send_header("content-type", "application/jsonl")
|
|
48
|
+
self.send_header("content-length", str(len(data)))
|
|
49
|
+
self.end_headers()
|
|
50
|
+
self.wfile.write(data)
|
|
51
|
+
return
|
|
52
|
+
self._json(404, {"message": "no"})
|
|
53
|
+
|
|
54
|
+
def do_POST(self) -> None:
|
|
55
|
+
length = int(self.headers.get("content-length") or 0)
|
|
56
|
+
body = json.loads(self.rfile.read(length) or b"{}")
|
|
57
|
+
if self.path == "/v1/models/register":
|
|
58
|
+
CALLS.append({"register": body})
|
|
59
|
+
return self._json(
|
|
60
|
+
201,
|
|
61
|
+
{
|
|
62
|
+
"name": body.get("name") or "ext-1",
|
|
63
|
+
"servedAs": body["servedAs"],
|
|
64
|
+
"status": "passed",
|
|
65
|
+
"gate": {"pass": True, "reasons": []},
|
|
66
|
+
},
|
|
67
|
+
)
|
|
68
|
+
self._json(404, {"message": "no"})
|
|
69
|
+
|
|
70
|
+
def log_message(self, format: str, *args: Any) -> None:
|
|
71
|
+
return
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@pytest.fixture()
|
|
75
|
+
def server() -> Any:
|
|
76
|
+
srv = HTTPServer(("127.0.0.1", 0), Handler)
|
|
77
|
+
t = threading.Thread(target=srv.serve_forever, daemon=True)
|
|
78
|
+
t.start()
|
|
79
|
+
CALLS.clear()
|
|
80
|
+
yield f"http://127.0.0.1:{srv.server_port}"
|
|
81
|
+
srv.shutdown()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_run_dry_exports_writes_manifest_and_registers(server: str, tmp_path: Path) -> None:
|
|
85
|
+
out = tmp_path / "model"
|
|
86
|
+
code = main(
|
|
87
|
+
[
|
|
88
|
+
"run",
|
|
89
|
+
"--base",
|
|
90
|
+
"Qwen/Qwen2.5-7B-Instruct",
|
|
91
|
+
"--out",
|
|
92
|
+
str(out),
|
|
93
|
+
"--backend",
|
|
94
|
+
"dry",
|
|
95
|
+
"--agent",
|
|
96
|
+
"ticket-triage",
|
|
97
|
+
"--min-rows",
|
|
98
|
+
"5",
|
|
99
|
+
"--register",
|
|
100
|
+
"local/triage-lora",
|
|
101
|
+
"--name",
|
|
102
|
+
"mine",
|
|
103
|
+
"--api-key",
|
|
104
|
+
"ov_test",
|
|
105
|
+
"--base-url",
|
|
106
|
+
server,
|
|
107
|
+
]
|
|
108
|
+
)
|
|
109
|
+
assert code == 0
|
|
110
|
+
assert (out / "train-sft.jsonl").read_text().count("\n") == 6
|
|
111
|
+
manifest = json.loads((out / "overturn-manifest.json").read_text())
|
|
112
|
+
assert manifest == {
|
|
113
|
+
"backend": "dry",
|
|
114
|
+
"base_model": "Qwen/Qwen2.5-7B-Instruct",
|
|
115
|
+
"method": "sft",
|
|
116
|
+
"rows": 6,
|
|
117
|
+
"epochs": 3,
|
|
118
|
+
"lora_r": 16,
|
|
119
|
+
"output_dir": str(out),
|
|
120
|
+
"seconds": manifest["seconds"],
|
|
121
|
+
"merged": False,
|
|
122
|
+
}
|
|
123
|
+
exp = next(c for c in CALLS if "export" in c)
|
|
124
|
+
assert "format=sft" in exp["export"] and "agent=ticket-triage" in exp["export"]
|
|
125
|
+
assert exp["auth"] == "Bearer ov_test"
|
|
126
|
+
reg = next(c for c in CALLS if "register" in c)["register"]
|
|
127
|
+
assert reg == {
|
|
128
|
+
"name": "mine",
|
|
129
|
+
"baseModel": "Qwen/Qwen2.5-7B-Instruct",
|
|
130
|
+
"servedAs": "local/triage-lora",
|
|
131
|
+
"method": "sft",
|
|
132
|
+
"epochs": 3,
|
|
133
|
+
"filter": {"agent": "ticket-triage"},
|
|
134
|
+
"datasetStats": {"rows": 6, "byKind": {"approve": 6}, "format": "sft"},
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def test_refuses_under_min_rows(server: str, tmp_path: Path) -> None:
|
|
139
|
+
code = main(
|
|
140
|
+
[
|
|
141
|
+
"run",
|
|
142
|
+
"--base",
|
|
143
|
+
"x/y",
|
|
144
|
+
"--out",
|
|
145
|
+
str(tmp_path),
|
|
146
|
+
"--backend",
|
|
147
|
+
"dry",
|
|
148
|
+
"--api-key",
|
|
149
|
+
"k",
|
|
150
|
+
"--base-url",
|
|
151
|
+
server,
|
|
152
|
+
]
|
|
153
|
+
)
|
|
154
|
+
assert code == 2
|
|
155
|
+
assert not (tmp_path / "overturn-manifest.json").exists()
|