troy-cli 0.1.2__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,6 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.egg-info/
4
+ dist/
5
+ output/
6
+ .DS_Store
@@ -0,0 +1,112 @@
1
+ Metadata-Version: 2.5
2
+ Name: troy-cli
3
+ Version: 0.1.2
4
+ Summary: Fine-tune LLMs on your MacBook with one YAML file. Built for Apple Silicon.
5
+ Author: Troy
6
+ License: Apache-2.0
7
+ Keywords: apple-silicon,dpo,fine-tuning,llm,lora,mlx
8
+ Requires-Python: >=3.10
9
+ Requires-Dist: mlx-lm>=0.30
10
+ Requires-Dist: pydantic>=2.5
11
+ Requires-Dist: pyyaml>=6.0
12
+ Requires-Dist: rich>=13.0
13
+ Requires-Dist: typer>=0.12
14
+ Description-Content-Type: text/markdown
15
+
16
+ # Troy
17
+
18
+ **Fine-tune LLMs on your MacBook with one YAML file.**
19
+
20
+ Troy is a command-line tool for fine-tuning and preference-tuning language
21
+ models locally on Apple Silicon. No CUDA, no cloud, no training pipeline —
22
+ write a config, run one command, and train on the machine you already own.
23
+
24
+ Built on [MLX](https://github.com/ml-explore/mlx) and
25
+ [mlx-lm](https://github.com/ml-explore/mlx-lm), Apple's ML framework for
26
+ Apple Silicon. Unified memory means a 36 GB MacBook fine-tunes models that
27
+ need a workstation GPU anywhere else.
28
+
29
+ ## Requirements
30
+
31
+ - Apple Silicon Mac (M1 or later)
32
+ - macOS 14+
33
+ - Python 3.10–3.12
34
+
35
+ ## Install
36
+
37
+ ```bash
38
+ brew install avirajkhare00/troy/troy
39
+ # or from source: pip install ./cli
40
+ ```
41
+
42
+ ## Quickstart
43
+
44
+ ```bash
45
+ troy doctor # check your Mac: chip, memory, MLX, what you can train
46
+ troy init # create troy.yaml + sample data
47
+ troy train # fine-tune (LoRA/QLoRA via MLX)
48
+ troy chat # talk to the result
49
+ troy serve # OpenAI-compatible API at localhost:8080/v1
50
+ troy export -f gguf # ship it to llama.cpp / Ollama / LM Studio
51
+ ```
52
+
53
+ ## The config is the interface
54
+
55
+ ```yaml
56
+ base: mlx-community/Qwen3-0.6B-4bit
57
+ task: sft # or: dpo
58
+
59
+ data:
60
+ train: ./data/train.jsonl # alpaca, sharegpt, chat, completions, text — auto-detected
61
+ val_split: 0.1
62
+
63
+ training:
64
+ epochs: 3
65
+ lr: 1e-5
66
+ batch_size: auto # sized from your Mac's unified memory
67
+ lora:
68
+ r: 8
69
+ alpha: 16
70
+
71
+ output: ./output
72
+ ```
73
+
74
+ ## Commands
75
+
76
+ | Command | Purpose |
77
+ |---|---|
78
+ | `troy init` | Create a config from a template (`chat`, `dpo`) |
79
+ | `troy doctor` | Hardware + dependency check, with model-size guidance |
80
+ | `troy train` | LoRA fine-tuning: SFT or DPO |
81
+ | `troy chat` | Interactive REPL (or `-p` for one-shot) with your adapter |
82
+ | `troy serve` | OpenAI-compatible API server for your model |
83
+ | `troy export` | Fuse the adapter; export MLX or GGUF |
84
+ | `troy data inspect` | Dataset stats and format detection |
85
+
86
+ ## What Troy can train on your Mac
87
+
88
+ | Unified memory | Max model (4-bit QLoRA) |
89
+ |---|---|
90
+ | 8 GB | ~1.5B |
91
+ | 16 GB | ~4B |
92
+ | 24 GB | ~8B |
93
+ | 36 GB | ~14B |
94
+ | 64 GB | ~32B |
95
+ | 128 GB | ~70B |
96
+
97
+ ## DPO without a second model
98
+
99
+ DPO normally keeps a frozen reference copy of the model in memory. Troy
100
+ zeroes the LoRA scales to recover the reference model from the policy model
101
+ itself — no second copy, which matters on unified memory.
102
+
103
+ ## Data formats
104
+
105
+ Auto-detected from the first record: Alpaca (`instruction`/`output`),
106
+ ShareGPT (`conversations`), chat (`messages`), `prompt`/`completion`,
107
+ plain `text`, and preference pairs (`prompt`/`chosen`/`rejected`) for DPO.
108
+ Files: `.jsonl`, `.json`, `.csv`.
109
+
110
+ ## License
111
+
112
+ Apache-2.0
@@ -0,0 +1,97 @@
1
+ # Troy
2
+
3
+ **Fine-tune LLMs on your MacBook with one YAML file.**
4
+
5
+ Troy is a command-line tool for fine-tuning and preference-tuning language
6
+ models locally on Apple Silicon. No CUDA, no cloud, no training pipeline —
7
+ write a config, run one command, and train on the machine you already own.
8
+
9
+ Built on [MLX](https://github.com/ml-explore/mlx) and
10
+ [mlx-lm](https://github.com/ml-explore/mlx-lm), Apple's ML framework for
11
+ Apple Silicon. Unified memory means a 36 GB MacBook fine-tunes models that
12
+ need a workstation GPU anywhere else.
13
+
14
+ ## Requirements
15
+
16
+ - Apple Silicon Mac (M1 or later)
17
+ - macOS 14+
18
+ - Python 3.10–3.12
19
+
20
+ ## Install
21
+
22
+ ```bash
23
+ brew install avirajkhare00/troy/troy
24
+ # or from source: pip install ./cli
25
+ ```
26
+
27
+ ## Quickstart
28
+
29
+ ```bash
30
+ troy doctor # check your Mac: chip, memory, MLX, what you can train
31
+ troy init # create troy.yaml + sample data
32
+ troy train # fine-tune (LoRA/QLoRA via MLX)
33
+ troy chat # talk to the result
34
+ troy serve # OpenAI-compatible API at localhost:8080/v1
35
+ troy export -f gguf # ship it to llama.cpp / Ollama / LM Studio
36
+ ```
37
+
38
+ ## The config is the interface
39
+
40
+ ```yaml
41
+ base: mlx-community/Qwen3-0.6B-4bit
42
+ task: sft # or: dpo
43
+
44
+ data:
45
+ train: ./data/train.jsonl # alpaca, sharegpt, chat, completions, text — auto-detected
46
+ val_split: 0.1
47
+
48
+ training:
49
+ epochs: 3
50
+ lr: 1e-5
51
+ batch_size: auto # sized from your Mac's unified memory
52
+ lora:
53
+ r: 8
54
+ alpha: 16
55
+
56
+ output: ./output
57
+ ```
58
+
59
+ ## Commands
60
+
61
+ | Command | Purpose |
62
+ |---|---|
63
+ | `troy init` | Create a config from a template (`chat`, `dpo`) |
64
+ | `troy doctor` | Hardware + dependency check, with model-size guidance |
65
+ | `troy train` | LoRA fine-tuning: SFT or DPO |
66
+ | `troy chat` | Interactive REPL (or `-p` for one-shot) with your adapter |
67
+ | `troy serve` | OpenAI-compatible API server for your model |
68
+ | `troy export` | Fuse the adapter; export MLX or GGUF |
69
+ | `troy data inspect` | Dataset stats and format detection |
70
+
71
+ ## What Troy can train on your Mac
72
+
73
+ | Unified memory | Max model (4-bit QLoRA) |
74
+ |---|---|
75
+ | 8 GB | ~1.5B |
76
+ | 16 GB | ~4B |
77
+ | 24 GB | ~8B |
78
+ | 36 GB | ~14B |
79
+ | 64 GB | ~32B |
80
+ | 128 GB | ~70B |
81
+
82
+ ## DPO without a second model
83
+
84
+ DPO normally keeps a frozen reference copy of the model in memory. Troy
85
+ zeroes the LoRA scales to recover the reference model from the policy model
86
+ itself — no second copy, which matters on unified memory.
87
+
88
+ ## Data formats
89
+
90
+ Auto-detected from the first record: Alpaca (`instruction`/`output`),
91
+ ShareGPT (`conversations`), chat (`messages`), `prompt`/`completion`,
92
+ plain `text`, and preference pairs (`prompt`/`chosen`/`rejected`) for DPO.
93
+ Files: `.jsonl`, `.json`, `.csv`.
94
+
95
+ ## License
96
+
97
+ Apache-2.0
@@ -0,0 +1,26 @@
1
+ [project]
2
+ name = "troy-cli"
3
+ version = "0.1.2"
4
+ description = "Fine-tune LLMs on your MacBook with one YAML file. Built for Apple Silicon."
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ license = { text = "Apache-2.0" }
8
+ authors = [{ name = "Troy" }]
9
+ keywords = ["mlx", "apple-silicon", "fine-tuning", "llm", "lora", "dpo"]
10
+ dependencies = [
11
+ "mlx-lm>=0.30",
12
+ "pydantic>=2.5",
13
+ "pyyaml>=6.0",
14
+ "typer>=0.12",
15
+ "rich>=13.0",
16
+ ]
17
+
18
+ [project.scripts]
19
+ troy = "troy.cli:app"
20
+
21
+ [build-system]
22
+ requires = ["hatchling"]
23
+ build-backend = "hatchling.build"
24
+
25
+ [tool.hatch.build.targets.wheel]
26
+ packages = ["src/troy"]
@@ -0,0 +1,3 @@
1
+ """Troy: fine-tune LLMs on your MacBook with one YAML file."""
2
+
3
+ __version__ = "0.1.2"
@@ -0,0 +1,57 @@
1
+ """Interactive chat with a trained model."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+ from mlx_lm.generate import stream_generate
8
+ from mlx_lm.sample_utils import make_sampler
9
+ from mlx_lm.utils import load
10
+
11
+
12
+ def run_chat(
13
+ model_path: str,
14
+ adapter_path: Optional[str] = None,
15
+ max_tokens: int = 512,
16
+ temperature: float = 0.7,
17
+ prompt: Optional[str] = None,
18
+ ) -> None:
19
+ print(f"Loading {model_path} ...")
20
+ model, tokenizer = load(model_path, adapter_path=adapter_path)
21
+ sampler = make_sampler(temp=temperature)
22
+ history = []
23
+
24
+ def respond(user_text: str) -> None:
25
+ history.append({"role": "user", "content": user_text})
26
+ templated = tokenizer.apply_chat_template(
27
+ history, add_generation_prompt=True, return_dict=False
28
+ )
29
+ reply = ""
30
+ for response in stream_generate(
31
+ model, tokenizer, templated, max_tokens=max_tokens, sampler=sampler
32
+ ):
33
+ print(response.text, end="", flush=True)
34
+ reply += response.text
35
+ print()
36
+ history.append({"role": "assistant", "content": reply})
37
+
38
+ if prompt is not None: # one-shot mode
39
+ respond(prompt)
40
+ return
41
+
42
+ print("Chat started. Type /exit to quit, /clear to reset history.\n")
43
+ while True:
44
+ try:
45
+ user_text = input(">> ").strip()
46
+ except (EOFError, KeyboardInterrupt):
47
+ print()
48
+ break
49
+ if not user_text:
50
+ continue
51
+ if user_text == "/exit":
52
+ break
53
+ if user_text == "/clear":
54
+ history.clear()
55
+ print("(history cleared)")
56
+ continue
57
+ respond(user_text)
@@ -0,0 +1,274 @@
1
+ """Troy CLI: fine-tune LLMs on your MacBook with one YAML file."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import platform
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import Optional
10
+
11
+ import typer
12
+ from rich.console import Console
13
+ from rich.table import Table
14
+
15
+ from . import __version__
16
+
17
+ app = typer.Typer(
18
+ name="troy",
19
+ help="Fine-tune LLMs on your MacBook with one YAML file. Built for Apple Silicon.",
20
+ no_args_is_help=True,
21
+ add_completion=False,
22
+ )
23
+ console = Console()
24
+
25
+
26
+ def _require_apple_silicon() -> None:
27
+ if platform.system() != "Darwin" or platform.machine() != "arm64":
28
+ console.print(
29
+ "[red]Troy runs on Apple Silicon Macs only (M1 or later).[/red]\n"
30
+ f"Detected: {platform.system()} / {platform.machine()}"
31
+ )
32
+ raise typer.Exit(1)
33
+
34
+
35
+ def _version_callback(value: bool) -> None:
36
+ if value:
37
+ console.print(f"troy {__version__}")
38
+ raise typer.Exit()
39
+
40
+
41
+ @app.callback()
42
+ def _main(
43
+ version: bool = typer.Option(
44
+ False, "--version", "-V", help="Show version.",
45
+ callback=_version_callback, is_eager=True,
46
+ ),
47
+ ) -> None:
48
+ pass
49
+
50
+
51
+ @app.command()
52
+ def init(
53
+ template: str = typer.Option(
54
+ "chat", help="Template: chat (SFT) or dpo (preference tuning)."
55
+ ),
56
+ path: Path = typer.Option(Path("troy.yaml"), help="Where to write the config."),
57
+ force: bool = typer.Option(False, "--force", help="Overwrite an existing config."),
58
+ ) -> None:
59
+ """Create a troy.yaml config (plus sample data) to start from."""
60
+ from .templates import TEMPLATES
61
+
62
+ if template not in TEMPLATES:
63
+ console.print(f"[red]Unknown template `{template}`.[/red] Options: {', '.join(TEMPLATES)}")
64
+ raise typer.Exit(1)
65
+ if path.exists() and not force:
66
+ console.print(f"[red]{path} already exists.[/red] Use --force to overwrite.")
67
+ raise typer.Exit(1)
68
+
69
+ config_text, data_name, sample = TEMPLATES[template]
70
+ path.write_text(config_text)
71
+
72
+ data_dir = path.parent / "data"
73
+ data_file = data_dir / data_name
74
+ if not data_file.exists():
75
+ data_dir.mkdir(parents=True, exist_ok=True)
76
+ with open(data_file, "w") as f:
77
+ for record in sample:
78
+ f.write(json.dumps(record) + "\n")
79
+ console.print(f"Wrote sample data to [bold]{data_file}[/bold] — replace it with yours.")
80
+
81
+ console.print(f"Created [bold]{path}[/bold] ({template} template).")
82
+ console.print("Next: edit the config, then run [bold]troy train[/bold].")
83
+
84
+
85
+ @app.command()
86
+ def doctor() -> None:
87
+ """Check this Mac's readiness for local fine-tuning."""
88
+ from .hardware import detect, model_guidance
89
+
90
+ hw = detect()
91
+ table = Table(title="troy doctor", show_header=False)
92
+ table.add_column(style="bold")
93
+ table.add_column()
94
+
95
+ ok = "[green]OK[/green]"
96
+ fail = "[red]FAIL[/red]"
97
+
98
+ table.add_row("Chip", hw.chip)
99
+ table.add_row(
100
+ "Apple Silicon", ok if hw.is_apple_silicon else f"{fail} ({hw.arch})"
101
+ )
102
+ table.add_row("Unified memory", f"{hw.memory_gb:.0f} GB")
103
+ table.add_row("macOS", hw.macos)
104
+ table.add_row("Free disk", f"{hw.free_disk_gb:.0f} GB")
105
+ table.add_row("Python", platform.python_version())
106
+
107
+ try:
108
+ import mlx.core as mx
109
+
110
+ table.add_row("MLX", f"{ok} (v{mx.__version__})")
111
+ gpu_ok = mx.default_device().type == mx.DeviceType.gpu
112
+ table.add_row("Metal GPU", ok if gpu_ok else f"{fail} (default device is CPU)")
113
+ except ImportError:
114
+ table.add_row("MLX", f"{fail} (not installed — `pip install mlx-lm`)")
115
+
116
+ try:
117
+ import mlx_lm
118
+
119
+ table.add_row("mlx-lm", f"{ok} (v{mlx_lm.__version__})")
120
+ except ImportError:
121
+ table.add_row("mlx-lm", f"{fail} (not installed)")
122
+
123
+ table.add_row("Fine-tunable models", model_guidance(hw.memory_gb))
124
+ console.print(table)
125
+
126
+ if not hw.is_apple_silicon:
127
+ raise typer.Exit(1)
128
+
129
+
130
+ @app.command()
131
+ def train(
132
+ config: Path = typer.Option(Path("troy.yaml"), "--config", "-c", help="Config file."),
133
+ ) -> None:
134
+ """Fine-tune a model from a troy.yaml config."""
135
+ _require_apple_silicon()
136
+ from .config import load_config
137
+ from .data import load_and_prepare
138
+
139
+ cfg = load_config(config)
140
+ train_records, valid_records, fmt = load_and_prepare(
141
+ cfg.data, cfg.task, cfg.training.seed
142
+ )
143
+ console.print(
144
+ f"Data: {len(train_records)} train / {len(valid_records)} valid "
145
+ f"(format: {fmt})"
146
+ )
147
+
148
+ if cfg.task == "sft":
149
+ from .train_sft import run_sft
150
+
151
+ run_sft(cfg, train_records, valid_records)
152
+ else:
153
+ from .train_dpo import run_dpo
154
+
155
+ run_dpo(cfg, train_records, valid_records)
156
+
157
+ console.print(
158
+ f"\nTry it: [bold]troy chat[/bold] | "
159
+ f"Export it: [bold]troy export[/bold]"
160
+ )
161
+
162
+
163
+ @app.command()
164
+ def chat(
165
+ config: Path = typer.Option(Path("troy.yaml"), "--config", "-c", help="Config file."),
166
+ model: Optional[str] = typer.Option(
167
+ None, help="Model path or HF repo (defaults to the config's base + trained adapter)."
168
+ ),
169
+ base_only: bool = typer.Option(False, help="Chat with the base model, no adapter."),
170
+ max_tokens: int = typer.Option(512),
171
+ temperature: float = typer.Option(0.7),
172
+ prompt: Optional[str] = typer.Option(None, "--prompt", "-p", help="One-shot prompt (no REPL)."),
173
+ ) -> None:
174
+ """Chat with your fine-tuned model."""
175
+ _require_apple_silicon()
176
+ from .chat import run_chat
177
+
178
+ adapter: Optional[str] = None
179
+ if model is None:
180
+ from .config import load_config
181
+
182
+ cfg = load_config(config)
183
+ model = cfg.base
184
+ if not base_only:
185
+ adapter_file = cfg.adapter_path / "adapters.safetensors"
186
+ if adapter_file.exists():
187
+ adapter = str(cfg.adapter_path)
188
+ else:
189
+ console.print(
190
+ "[yellow]No trained adapter found — chatting with the base model.[/yellow]"
191
+ )
192
+ run_chat(model, adapter, max_tokens, temperature, prompt)
193
+
194
+
195
+ @app.command()
196
+ def serve(
197
+ config: Path = typer.Option(Path("troy.yaml"), "--config", "-c", help="Config file."),
198
+ model: Optional[str] = typer.Option(
199
+ None, help="Model path or HF repo (defaults to the config's base + trained adapter)."
200
+ ),
201
+ base_only: bool = typer.Option(False, help="Serve the base model, no adapter."),
202
+ host: str = typer.Option("127.0.0.1"),
203
+ port: int = typer.Option(8080),
204
+ max_tokens: int = typer.Option(512, help="Default max tokens per response."),
205
+ ) -> None:
206
+ """Serve your model over an OpenAI-compatible API."""
207
+ _require_apple_silicon()
208
+ from .serve import run_serve
209
+
210
+ adapter: Optional[str] = None
211
+ if model is None:
212
+ from .config import load_config
213
+
214
+ cfg = load_config(config)
215
+ model = cfg.base
216
+ if not base_only:
217
+ adapter_file = cfg.adapter_path / "adapters.safetensors"
218
+ if adapter_file.exists():
219
+ adapter = str(cfg.adapter_path)
220
+ else:
221
+ console.print(
222
+ "[yellow]No trained adapter found — serving the base model.[/yellow]"
223
+ )
224
+ run_serve(model, adapter, host, port, max_tokens)
225
+
226
+
227
+ @app.command()
228
+ def export(
229
+ config: Path = typer.Option(Path("troy.yaml"), "--config", "-c", help="Config file."),
230
+ fmt: str = typer.Option("mlx", "--format", "-f", help="Export format: mlx or gguf."),
231
+ save_path: Optional[Path] = typer.Option(None, help="Output directory (default: <output>/fused)."),
232
+ dequantize: bool = typer.Option(False, help="Dequantize when fusing a quantized base."),
233
+ ) -> None:
234
+ """Merge the trained adapter into the base model and export it."""
235
+ _require_apple_silicon()
236
+ if fmt not in ("mlx", "gguf"):
237
+ console.print("[red]--format must be `mlx` or `gguf`.[/red]")
238
+ raise typer.Exit(1)
239
+ from .config import load_config
240
+ from .export import run_export
241
+
242
+ cfg = load_config(config)
243
+ adapter_file = cfg.adapter_path / "adapters.safetensors"
244
+ if not adapter_file.exists():
245
+ console.print(f"[red]No adapter at {adapter_file}. Run `troy train` first.[/red]")
246
+ raise typer.Exit(1)
247
+ out = save_path or (cfg.output_path / "fused")
248
+ run_export(cfg.base, str(cfg.adapter_path), str(out), fmt, dequantize)
249
+
250
+
251
+ @app.command()
252
+ def data(
253
+ action: str = typer.Argument(help="Action: inspect"),
254
+ path: Path = typer.Argument(help="Dataset file (.jsonl, .json, .csv)."),
255
+ ) -> None:
256
+ """Inspect a dataset: record count, detected format, sizes."""
257
+ if action != "inspect":
258
+ console.print("[red]Only `troy data inspect <path>` is supported.[/red]")
259
+ raise typer.Exit(1)
260
+ from .data import inspect_stats
261
+
262
+ stats = inspect_stats(str(path))
263
+ table = Table(title=str(path), show_header=False)
264
+ table.add_column(style="bold")
265
+ table.add_column()
266
+ table.add_row("Records", str(stats["records"]))
267
+ table.add_row("Detected format", stats["format"])
268
+ table.add_row("Avg record size", f"{stats['avg_chars']:.0f} chars")
269
+ table.add_row("Max record size", f"{stats['max_chars']} chars")
270
+ console.print(table)
271
+
272
+
273
+ if __name__ == "__main__":
274
+ app()
@@ -0,0 +1,82 @@
1
+ """Troy YAML config schema and loader."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Literal, Optional, Union
7
+
8
+ import yaml
9
+ from pydantic import BaseModel, Field, field_validator
10
+
11
+
12
+ class LoraConfig(BaseModel):
13
+ r: int = 8
14
+ alpha: float = 16.0
15
+ dropout: float = 0.0
16
+ layers: Union[int, Literal["all"]] = 16 # decoder layers to adapt
17
+
18
+ @property
19
+ def scale(self) -> float:
20
+ return self.alpha / self.r
21
+
22
+
23
+ class DpoConfig(BaseModel):
24
+ beta: float = 0.1
25
+
26
+
27
+ class DataConfig(BaseModel):
28
+ train: str
29
+ valid: Optional[str] = None
30
+ format: Literal[
31
+ "auto", "alpaca", "sharegpt", "chat", "completions", "text", "preference"
32
+ ] = "auto"
33
+ val_split: float = Field(0.1, ge=0.0, lt=1.0)
34
+ mask_prompt: bool = False
35
+
36
+
37
+ class TrainingConfig(BaseModel):
38
+ epochs: Optional[float] = None # translated to iters from dataset size
39
+ iters: Optional[int] = None # takes precedence over epochs
40
+ lr: float = 1e-5
41
+ batch_size: Union[int, Literal["auto"]] = "auto"
42
+ seq_len: int = 2048
43
+ lora: LoraConfig = LoraConfig()
44
+ dpo: DpoConfig = DpoConfig()
45
+ grad_checkpoint: bool = False
46
+ grad_accumulation_steps: int = 1
47
+ save_every: int = 100
48
+ seed: int = 0
49
+
50
+
51
+ class TroyConfig(BaseModel):
52
+ base: str
53
+ task: Literal["sft", "dpo"] = "sft"
54
+ data: DataConfig
55
+ training: TrainingConfig = TrainingConfig()
56
+ output: str = "./output"
57
+
58
+ @field_validator("base")
59
+ @classmethod
60
+ def _non_empty(cls, v: str) -> str:
61
+ if not v.strip():
62
+ raise ValueError("`base` must be a HuggingFace repo id or local path")
63
+ return v
64
+
65
+ @property
66
+ def output_path(self) -> Path:
67
+ return Path(self.output).expanduser()
68
+
69
+ @property
70
+ def adapter_path(self) -> Path:
71
+ return self.output_path / "adapter"
72
+
73
+
74
+ def load_config(path: Union[str, Path]) -> TroyConfig:
75
+ path = Path(path)
76
+ if not path.exists():
77
+ raise FileNotFoundError(
78
+ f"Config not found: {path}. Run `troy init` to create one."
79
+ )
80
+ with open(path) as f:
81
+ raw = yaml.safe_load(f) or {}
82
+ return TroyConfig.model_validate(raw)