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.
@@ -0,0 +1,3 @@
1
+ .venv/
2
+ dist/
3
+ __pycache__/
@@ -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,7 @@
1
+ """overturn-trainer: fine-tune an open-weight model on your Overturn corrections, in your own cloud."""
2
+
3
+ from .api import OverturnApi
4
+ from .train import TrainResult, train
5
+
6
+ __all__ = ["OverturnApi", "TrainResult", "train"]
7
+ __version__ = "0.1.0"
@@ -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()