troy-cli 0.1.2__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.
troy/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Troy: fine-tune LLMs on your MacBook with one YAML file."""
2
+
3
+ __version__ = "0.1.2"
troy/chat.py ADDED
@@ -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)
troy/cli.py ADDED
@@ -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()
troy/config.py ADDED
@@ -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)
troy/data.py ADDED
@@ -0,0 +1,163 @@
1
+ """Dataset loading, format auto-detection, and conversion to mlx-lm formats."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import csv
6
+ import json
7
+ import random
8
+ from pathlib import Path
9
+ from typing import Any, Dict, List, Optional, Tuple
10
+
11
+ from .config import DataConfig
12
+
13
+
14
+ def _read_records(path: Path) -> List[Dict[str, Any]]:
15
+ suffix = path.suffix.lower()
16
+ if suffix == ".jsonl":
17
+ with open(path) as f:
18
+ return [json.loads(line) for line in f if line.strip()]
19
+ if suffix == ".json":
20
+ with open(path) as f:
21
+ data = json.load(f)
22
+ if not isinstance(data, list):
23
+ raise ValueError(f"{path}: JSON file must contain a list of records")
24
+ return data
25
+ if suffix == ".csv":
26
+ with open(path, newline="") as f:
27
+ return list(csv.DictReader(f))
28
+ raise ValueError(
29
+ f"Unsupported file type: {path.suffix} (use .jsonl, .json, or .csv)"
30
+ )
31
+
32
+
33
+ def detect_format(sample: Dict[str, Any]) -> str:
34
+ keys = set(sample)
35
+ if {"prompt", "chosen", "rejected"} <= keys:
36
+ return "preference"
37
+ if "messages" in keys:
38
+ return "chat"
39
+ if "conversations" in keys:
40
+ return "sharegpt"
41
+ if {"instruction", "output"} <= keys:
42
+ return "alpaca"
43
+ if {"prompt", "completion"} <= keys:
44
+ return "completions"
45
+ if "text" in keys:
46
+ return "text"
47
+ raise ValueError(
48
+ f"Could not detect data format from keys: {sorted(keys)}. "
49
+ "Set `data.format` explicitly in troy.yaml."
50
+ )
51
+
52
+
53
+ _SHAREGPT_ROLES = {
54
+ "human": "user",
55
+ "user": "user",
56
+ "gpt": "assistant",
57
+ "assistant": "assistant",
58
+ "system": "system",
59
+ }
60
+
61
+
62
+ def _to_messages(record: Dict[str, Any], fmt: str) -> Dict[str, Any]:
63
+ """Normalize an SFT record to mlx-lm chat format ({"messages": [...]})."""
64
+ if fmt == "chat":
65
+ return {"messages": record["messages"]}
66
+ if fmt == "sharegpt":
67
+ messages = [
68
+ {"role": _SHAREGPT_ROLES[m["from"].lower()], "content": m["value"]}
69
+ for m in record["conversations"]
70
+ ]
71
+ return {"messages": messages}
72
+ if fmt == "alpaca":
73
+ user = record["instruction"]
74
+ if record.get("input"):
75
+ user = f"{user}\n\n{record['input']}"
76
+ messages = []
77
+ if record.get("system"):
78
+ messages.append({"role": "system", "content": record["system"]})
79
+ messages += [
80
+ {"role": "user", "content": user},
81
+ {"role": "assistant", "content": record["output"]},
82
+ ]
83
+ return {"messages": messages}
84
+ if fmt in ("completions", "text"):
85
+ return record # already an mlx-lm native format
86
+ raise ValueError(f"Unexpected format: {fmt}")
87
+
88
+
89
+ def _normalize_preference(record: Dict[str, Any]) -> Dict[str, str]:
90
+ """Normalize a DPO record to string prompt/chosen/rejected."""
91
+
92
+ def text_of(v: Any) -> str:
93
+ if isinstance(v, str):
94
+ return v
95
+ if isinstance(v, list): # message-list style
96
+ return "\n".join(m.get("content", "") for m in v)
97
+ raise ValueError(f"Cannot interpret preference field: {v!r}")
98
+
99
+ return {
100
+ "prompt": text_of(record["prompt"]),
101
+ "chosen": text_of(record["chosen"]),
102
+ "rejected": text_of(record["rejected"]),
103
+ }
104
+
105
+
106
+ def load_and_prepare(
107
+ cfg: DataConfig, task: str, seed: int = 0
108
+ ) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]], str]:
109
+ """Load train/valid records normalized for the task.
110
+
111
+ Returns (train_records, valid_records, detected_format).
112
+ """
113
+ train_path = Path(cfg.train).expanduser()
114
+ records = _read_records(train_path)
115
+ if not records:
116
+ raise ValueError(f"No records found in {train_path}")
117
+
118
+ fmt = cfg.format if cfg.format != "auto" else detect_format(records[0])
119
+
120
+ if task == "dpo" and fmt != "preference":
121
+ raise ValueError(
122
+ f"Task `dpo` needs preference data (prompt/chosen/rejected); "
123
+ f"detected format `{fmt}`."
124
+ )
125
+ if task == "sft" and fmt == "preference":
126
+ raise ValueError("Preference data detected — set `task: dpo` in troy.yaml.")
127
+
128
+ if fmt == "preference":
129
+ records = [_normalize_preference(r) for r in records]
130
+ else:
131
+ records = [_to_messages(r, fmt) for r in records]
132
+
133
+ if cfg.valid:
134
+ valid = _read_records(Path(cfg.valid).expanduser())
135
+ valid = (
136
+ [_normalize_preference(r) for r in valid]
137
+ if fmt == "preference"
138
+ else [_to_messages(r, fmt) for r in valid]
139
+ )
140
+ return records, valid, fmt
141
+
142
+ # Split off validation
143
+ rng = random.Random(seed)
144
+ indices = list(range(len(records)))
145
+ rng.shuffle(indices)
146
+ n_val = int(len(records) * cfg.val_split)
147
+ val_idx = set(indices[:n_val])
148
+ train = [r for i, r in enumerate(records) if i not in val_idx]
149
+ valid = [r for i, r in enumerate(records) if i in val_idx]
150
+ return train, valid, fmt
151
+
152
+
153
+ def inspect_stats(path: str) -> Dict[str, Any]:
154
+ """Lightweight dataset statistics for `troy data inspect`-style output."""
155
+ records = _read_records(Path(path).expanduser())
156
+ fmt = detect_format(records[0]) if records else "unknown"
157
+ lengths = [len(json.dumps(r)) for r in records]
158
+ return {
159
+ "records": len(records),
160
+ "format": fmt,
161
+ "avg_chars": sum(lengths) / max(len(lengths), 1),
162
+ "max_chars": max(lengths, default=0),
163
+ }
troy/export.py ADDED
@@ -0,0 +1,81 @@
1
+ """Merge adapters and export to deployment formats (MLX, GGUF)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ import sys
7
+ from pathlib import Path
8
+
9
+
10
+ def _fuse(base: str, adapter_path: str, save_path: str, dequantize: bool) -> None:
11
+ cmd = [
12
+ sys.executable,
13
+ "-m",
14
+ "mlx_lm",
15
+ "fuse",
16
+ "--model",
17
+ base,
18
+ "--adapter-path",
19
+ adapter_path,
20
+ "--save-path",
21
+ save_path,
22
+ ]
23
+ if dequantize:
24
+ cmd.append("--dequantize")
25
+ print("Fusing adapter into base model ...")
26
+ result = subprocess.run(cmd)
27
+ if result.returncode != 0:
28
+ raise SystemExit(result.returncode)
29
+
30
+
31
+ def _export_gguf(save_path: Path) -> Path:
32
+ """Convert a fused MLX model directory to GGUF (llama/mistral/mixtral archs)."""
33
+ import json
34
+
35
+ import mlx.core as mx
36
+ from mlx_lm import gguf as gguf_mod
37
+
38
+ with open(save_path / "config.json") as f:
39
+ config = json.load(f)
40
+
41
+ weights = {}
42
+ for part in sorted(save_path.glob("*.safetensors")):
43
+ weights.update(mx.load(str(part)))
44
+
45
+ # mlx-lm's converter permutes attention weights into non-contiguous views,
46
+ # which save_gguf rejects — force contiguity on its output.
47
+ orig_permute = gguf_mod.permute_weights
48
+ gguf_mod.permute_weights = lambda *a, **k: mx.contiguous(orig_permute(*a, **k))
49
+ try:
50
+ out = save_path / "ggml-model-f16.gguf"
51
+ gguf_mod.convert_to_gguf(save_path, weights, config, str(out))
52
+ finally:
53
+ gguf_mod.permute_weights = orig_permute
54
+ return out
55
+
56
+
57
+ def run_export(
58
+ base: str,
59
+ adapter_path: str,
60
+ save_path: str,
61
+ fmt: str = "mlx",
62
+ dequantize: bool = False,
63
+ ) -> None:
64
+ """Fuse a LoRA adapter into the base model; optionally convert to GGUF.
65
+
66
+ fmt: "mlx" (fused MLX weights) or "gguf" (also writes ggml-model-f16.gguf
67
+ for llama.cpp / Ollama / LM Studio; llama, mistral and mixtral
68
+ architectures, unquantized base).
69
+ """
70
+ if not Path(base).exists():
71
+ # mlx-lm's training path downloads a partial snapshot; fuse checks for
72
+ # a complete one. Top it up before fusing.
73
+ from huggingface_hub import snapshot_download
74
+
75
+ snapshot_download(base)
76
+
77
+ _fuse(base, adapter_path, save_path, dequantize)
78
+ print(f"Fused model: {Path(save_path).resolve()}")
79
+ if fmt == "gguf":
80
+ out = _export_gguf(Path(save_path))
81
+ print(f"GGUF: {out.resolve()}")
troy/hardware.py ADDED
@@ -0,0 +1,68 @@
1
+ """Apple Silicon hardware detection and guidance."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import platform
6
+ import shutil
7
+ import subprocess
8
+ from dataclasses import dataclass
9
+
10
+
11
+ def _sysctl(key: str) -> str:
12
+ try:
13
+ return subprocess.run(
14
+ ["sysctl", "-n", key], capture_output=True, text=True, timeout=5
15
+ ).stdout.strip()
16
+ except Exception:
17
+ return ""
18
+
19
+
20
+ @dataclass
21
+ class Hardware:
22
+ chip: str
23
+ memory_gb: float
24
+ macos: str
25
+ arch: str
26
+ free_disk_gb: float
27
+
28
+ @property
29
+ def is_apple_silicon(self) -> bool:
30
+ return self.arch == "arm64" and platform.system() == "Darwin"
31
+
32
+
33
+ def detect() -> Hardware:
34
+ mem_bytes = int(_sysctl("hw.memsize") or 0)
35
+ total, used, free = shutil.disk_usage("/")
36
+ return Hardware(
37
+ chip=_sysctl("machdep.cpu.brand_string") or "unknown",
38
+ memory_gb=mem_bytes / 2**30,
39
+ macos=platform.mac_ver()[0] or "unknown",
40
+ arch=platform.machine(),
41
+ free_disk_gb=free / 2**30,
42
+ )
43
+
44
+
45
+ # (min unified memory GB, guidance) — QLoRA 4-bit fine-tuning headroom.
46
+ MODEL_GUIDANCE = [
47
+ (128, "up to ~70B (4-bit QLoRA), e.g. Llama-3.3-70B, Qwen2.5-72B"),
48
+ (64, "up to ~32B (4-bit QLoRA), e.g. Qwen2.5-32B, Gemma-2-27B"),
49
+ (36, "up to ~14B (4-bit QLoRA), e.g. Qwen2.5-14B, Phi-4"),
50
+ (24, "up to ~8B (4-bit QLoRA), e.g. Llama-3.1-8B, Qwen3-8B"),
51
+ (16, "up to ~4B (4-bit QLoRA), e.g. Qwen3-4B, Phi-3.5-mini, Gemma-3-4B"),
52
+ (8, "up to ~1.5B (4-bit QLoRA), e.g. Qwen3-0.6B, Llama-3.2-1B"),
53
+ ]
54
+
55
+
56
+ def model_guidance(memory_gb: float) -> str:
57
+ for min_gb, text in MODEL_GUIDANCE:
58
+ if memory_gb >= min_gb:
59
+ return text
60
+ return "very small models only (<1B)"
61
+
62
+
63
+ def auto_batch_size(memory_gb: float, seq_len: int) -> int:
64
+ """Conservative batch size from unified memory and sequence length."""
65
+ budget = max(memory_gb - 8, 1) # leave room for the OS + model weights
66
+ per_seq = seq_len / 2048 # rough scaling
67
+ bs = int(budget // (4 * per_seq))
68
+ return max(1, min(bs, 8))
troy/serve.py ADDED
@@ -0,0 +1,45 @@
1
+ """OpenAI-compatible local API server (wraps the mlx-lm HTTP server)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ import sys
7
+ from typing import Optional
8
+
9
+
10
+ def run_serve(
11
+ model: str,
12
+ adapter_path: Optional[str],
13
+ host: str,
14
+ port: int,
15
+ max_tokens: int,
16
+ ) -> None:
17
+ cmd = [
18
+ sys.executable,
19
+ "-m",
20
+ "mlx_lm",
21
+ "server",
22
+ "--model",
23
+ model,
24
+ "--host",
25
+ host,
26
+ "--port",
27
+ str(port),
28
+ "--max-tokens",
29
+ str(max_tokens),
30
+ ]
31
+ if adapter_path:
32
+ cmd += ["--adapter-path", adapter_path]
33
+
34
+ print(f"Serving {model}" + (f" + adapter {adapter_path}" if adapter_path else ""))
35
+ print(f"OpenAI-compatible API at http://{host}:{port}/v1")
36
+ print(
37
+ "Try it:\n"
38
+ f' curl http://{host}:{port}/v1/chat/completions \\\n'
39
+ ' -H "Content-Type: application/json" \\\n'
40
+ ' -d \'{"messages": [{"role": "user", "content": "Hello"}]}\'\n'
41
+ )
42
+ try:
43
+ raise SystemExit(subprocess.run(cmd).returncode)
44
+ except KeyboardInterrupt:
45
+ print("\nServer stopped.")
troy/templates.py ADDED
@@ -0,0 +1,84 @@
1
+ """Config and sample-data templates for `troy init`."""
2
+
3
+ CHAT_TEMPLATE = """\
4
+ # Troy config — supervised fine-tuning (SFT)
5
+ # Run with: troy train
6
+
7
+ base: mlx-community/Qwen3-0.6B-4bit # any MLX model on the HF Hub, or a local path
8
+ task: sft
9
+
10
+ data:
11
+ train: ./data/train.jsonl # alpaca, sharegpt, chat, completions, or text — auto-detected
12
+ format: auto
13
+ val_split: 0.1
14
+
15
+ training:
16
+ epochs: 3
17
+ lr: 1e-5
18
+ batch_size: auto # sized from your Mac's unified memory
19
+ seq_len: 2048
20
+ lora:
21
+ r: 8
22
+ alpha: 16
23
+
24
+ output: ./output
25
+ """
26
+
27
+ DPO_TEMPLATE = """\
28
+ # Troy config — preference tuning (DPO)
29
+ # Run with: troy train
30
+
31
+ base: mlx-community/Qwen3-0.6B-4bit
32
+ task: dpo
33
+
34
+ data:
35
+ train: ./data/preferences.jsonl # {"prompt": ..., "chosen": ..., "rejected": ...}
36
+ format: preference
37
+ val_split: 0.05
38
+
39
+ training:
40
+ epochs: 1
41
+ lr: 5e-6
42
+ batch_size: auto
43
+ seq_len: 2048
44
+ lora:
45
+ r: 8
46
+ alpha: 16
47
+ dpo:
48
+ beta: 0.1
49
+
50
+ output: ./output
51
+ """
52
+
53
+ SAMPLE_SFT_DATA = [
54
+ {
55
+ "instruction": "What is the capital of France?",
56
+ "output": "The capital of France is Paris.",
57
+ },
58
+ {
59
+ "instruction": "Write a haiku about the ocean.",
60
+ "output": "Endless blue expanse\nWaves whisper against the shore\nSalt hangs in the air",
61
+ },
62
+ {
63
+ "instruction": "Explain what a LoRA adapter is in one sentence.",
64
+ "output": "A LoRA adapter is a small set of low-rank matrices trained alongside a frozen model so it can be specialized cheaply.",
65
+ },
66
+ ]
67
+
68
+ SAMPLE_DPO_DATA = [
69
+ {
70
+ "prompt": "What is the capital of France?",
71
+ "chosen": "The capital of France is Paris.",
72
+ "rejected": "I think it might be Lyon, but I'm not sure.",
73
+ },
74
+ {
75
+ "prompt": "Summarize photosynthesis in one sentence.",
76
+ "chosen": "Photosynthesis is the process by which plants convert sunlight, water, and carbon dioxide into glucose and oxygen.",
77
+ "rejected": "Plants eat sunlight.",
78
+ },
79
+ ]
80
+
81
+ TEMPLATES = {
82
+ "chat": (CHAT_TEMPLATE, "train.jsonl", SAMPLE_SFT_DATA),
83
+ "dpo": (DPO_TEMPLATE, "preferences.jsonl", SAMPLE_DPO_DATA),
84
+ }
troy/train_dpo.py ADDED
@@ -0,0 +1,174 @@
1
+ """Direct Preference Optimization on Apple Silicon.
2
+
3
+ Memory trick: with LoRA, the frozen reference model is the policy model with
4
+ every adapter's scale set to 0 — so DPO needs no second copy of the weights,
5
+ which matters on unified memory.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import time
11
+ from typing import Any, Dict, List, Tuple
12
+
13
+ import mlx.core as mx
14
+ import mlx.nn as nn
15
+ import mlx.optimizers as optim
16
+ from mlx.utils import tree_flatten
17
+ from mlx_lm.tuner.trainer import grad_checkpoint
18
+ from mlx_lm.tuner.utils import print_trainable_parameters
19
+ from mlx_lm.utils import load
20
+
21
+ from .config import TroyConfig
22
+ from .train_sft import apply_lora, resolve_batch_size, resolve_iters, save_adapter_config
23
+
24
+
25
+ def _lora_modules(model) -> list:
26
+ return [
27
+ m
28
+ for _, m in model.named_modules()
29
+ if hasattr(m, "lora_a") and hasattr(m, "scale")
30
+ ]
31
+
32
+
33
+ class _ReferenceMode:
34
+ """Context manager: zero every LoRA scale so the model acts as the frozen base."""
35
+
36
+ def __init__(self, model):
37
+ self.modules = _lora_modules(model)
38
+ self.saved: List[float] = []
39
+
40
+ def __enter__(self):
41
+ self.saved = [m.scale for m in self.modules]
42
+ for m in self.modules:
43
+ m.scale = 0.0
44
+
45
+ def __exit__(self, *exc):
46
+ for m, s in zip(self.modules, self.saved):
47
+ m.scale = s
48
+
49
+
50
+ def _encode_pair(tokenizer, prompt: str, completion: str, max_len: int) -> Tuple[List[int], int]:
51
+ """Tokenize prompt+completion with the chat template; return (tokens, prompt_len)."""
52
+ messages = [{"role": "user", "content": prompt}]
53
+ prompt_tokens = tokenizer.apply_chat_template(
54
+ messages, add_generation_prompt=True, return_dict=False
55
+ )
56
+ full = messages + [{"role": "assistant", "content": completion}]
57
+ full_tokens = tokenizer.apply_chat_template(full, return_dict=False)
58
+ full_tokens = full_tokens[:max_len]
59
+ prompt_len = min(len(prompt_tokens), len(full_tokens) - 1)
60
+ return full_tokens, prompt_len
61
+
62
+
63
+ def _batch(
64
+ pairs: List[Tuple[List[int], int]], pad_id: int
65
+ ) -> Tuple[mx.array, mx.array]:
66
+ """Pad a list of (tokens, prompt_len) into (tokens, completion_mask) arrays."""
67
+ max_len = max(len(t) for t, _ in pairs)
68
+ tokens, mask = [], []
69
+ for t, plen in pairs:
70
+ pad = max_len - len(t)
71
+ tokens.append(list(t) + [pad_id] * pad)
72
+ # mask over positions whose *target* token is part of the completion
73
+ m = [1.0 if plen <= j < len(t) else 0.0 for j in range(1, max_len)]
74
+ mask.append(m)
75
+ return mx.array(tokens), mx.array(mask)
76
+
77
+
78
+ def _sequence_logps(model, tokens: mx.array, mask: mx.array) -> mx.array:
79
+ """Sum of per-token log-probs over the completion for each sequence."""
80
+ logits = model(tokens[:, :-1])
81
+ targets = tokens[:, 1:]
82
+ logps = -nn.losses.cross_entropy(logits, targets, reduction="none")
83
+ return (logps * mask).sum(axis=-1)
84
+
85
+
86
+ def run_dpo(
87
+ config: TroyConfig,
88
+ train_records: List[Dict[str, Any]],
89
+ valid_records: List[Dict[str, Any]],
90
+ ) -> None:
91
+ mx.random.seed(config.training.seed)
92
+ print(f"Loading {config.base} ...")
93
+ model, tokenizer = load(config.base)
94
+
95
+ batch_size = resolve_batch_size(config)
96
+ # DPO runs chosen+rejected per example: halve the auto batch size
97
+ if config.training.batch_size == "auto":
98
+ batch_size = max(1, batch_size // 2)
99
+ iters = resolve_iters(config, len(train_records), batch_size)
100
+ num_layers = apply_lora(model, config)
101
+ save_adapter_config(config, num_layers)
102
+ if config.training.grad_checkpoint:
103
+ grad_checkpoint(model.layers[0])
104
+
105
+ beta = config.training.dpo.beta
106
+ max_len = config.training.seq_len
107
+ pad_id = tokenizer.pad_token_id or tokenizer.eos_token_id or 0
108
+
109
+ encoded = [
110
+ (
111
+ _encode_pair(tokenizer, r["prompt"], r["chosen"], max_len),
112
+ _encode_pair(tokenizer, r["prompt"], r["rejected"], max_len),
113
+ )
114
+ for r in train_records
115
+ ]
116
+
117
+ def loss_fn(model, tc, mc, tr, mr, ref_c, ref_r):
118
+ pol_c = _sequence_logps(model, tc, mc)
119
+ pol_r = _sequence_logps(model, tr, mr)
120
+ logits = beta * ((pol_c - ref_c) - (pol_r - ref_r))
121
+ loss = -nn.log_sigmoid(logits).mean()
122
+ reward_acc = (logits > 0).mean()
123
+ return loss, reward_acc
124
+
125
+ loss_and_grad = nn.value_and_grad(model, loss_fn)
126
+ opt = optim.Adam(learning_rate=config.training.lr)
127
+
128
+ print(
129
+ f"Training: task=dpo beta={beta} batch_size={batch_size} iters={iters} "
130
+ f"lr={config.training.lr} pairs={len(encoded)}"
131
+ )
132
+
133
+ config.adapter_path.mkdir(parents=True, exist_ok=True)
134
+ adapter_file = config.adapter_path / "adapters.safetensors"
135
+ n = len(encoded)
136
+ losses, accs = [], []
137
+ start = time.time()
138
+
139
+ for it in range(iters):
140
+ idx = [(it * batch_size + k) % n for k in range(batch_size)]
141
+ chosen = [encoded[i][0] for i in idx]
142
+ rejected = [encoded[i][1] for i in idx]
143
+ tc, mc = _batch(chosen, pad_id)
144
+ tr, mr = _batch(rejected, pad_id)
145
+
146
+ with _ReferenceMode(model):
147
+ ref_c = mx.stop_gradient(_sequence_logps(model, tc, mc))
148
+ ref_r = mx.stop_gradient(_sequence_logps(model, tr, mr))
149
+ mx.eval(ref_c, ref_r)
150
+
151
+ (loss, acc), grads = loss_and_grad(model, tc, mc, tr, mr, ref_c, ref_r)
152
+ opt.update(model, grads)
153
+ mx.eval(model.parameters(), opt.state, loss)
154
+ losses.append(loss.item())
155
+ accs.append(acc.item())
156
+
157
+ if (it + 1) % 10 == 0 or it == iters - 1:
158
+ speed = (it + 1) / (time.time() - start)
159
+ print(
160
+ f"Iter {it + 1}/{iters}: loss {sum(losses)/len(losses):.4f}, "
161
+ f"reward acc {sum(accs)/len(accs):.3f}, {speed:.2f} it/s"
162
+ )
163
+ losses, accs = [], []
164
+
165
+ if (it + 1) % config.training.save_every == 0:
166
+ _save(model, adapter_file)
167
+
168
+ _save(model, adapter_file)
169
+ print(f"\nDone. Adapter saved to {config.adapter_path}")
170
+
171
+
172
+ def _save(model, adapter_file) -> None:
173
+ adapter_weights = dict(tree_flatten(model.trainable_parameters()))
174
+ mx.save_safetensors(str(adapter_file), adapter_weights)
troy/train_sft.py ADDED
@@ -0,0 +1,113 @@
1
+ """Supervised fine-tuning via the mlx-lm tuner."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import math
7
+ import types
8
+ from typing import Any, Dict, List
9
+
10
+ import mlx.core as mx
11
+ import mlx.optimizers as optim
12
+ from mlx_lm.tuner.datasets import CacheDataset, create_dataset
13
+ from mlx_lm.tuner.trainer import TrainingArgs, train
14
+ from mlx_lm.tuner.utils import linear_to_lora_layers, print_trainable_parameters
15
+ from mlx_lm.utils import load
16
+
17
+ from .config import TroyConfig
18
+ from .hardware import auto_batch_size, detect
19
+
20
+
21
+ def resolve_batch_size(config: TroyConfig) -> int:
22
+ if config.training.batch_size == "auto":
23
+ return auto_batch_size(detect().memory_gb, config.training.seq_len)
24
+ return int(config.training.batch_size)
25
+
26
+
27
+ def resolve_iters(config: TroyConfig, n_train: int, batch_size: int) -> int:
28
+ t = config.training
29
+ if t.iters:
30
+ return t.iters
31
+ epochs = t.epochs or 3
32
+ return max(1, math.ceil(epochs * n_train / batch_size))
33
+
34
+
35
+ def apply_lora(model, config: TroyConfig):
36
+ model.freeze()
37
+ lora = config.training.lora
38
+ n_model_layers = len(model.layers)
39
+ num_layers = (
40
+ n_model_layers if lora.layers == "all" else min(int(lora.layers), n_model_layers)
41
+ )
42
+ linear_to_lora_layers(
43
+ model,
44
+ num_layers,
45
+ {"rank": lora.r, "scale": lora.scale, "dropout": lora.dropout},
46
+ )
47
+ print_trainable_parameters(model)
48
+ return num_layers
49
+
50
+
51
+ def save_adapter_config(config: TroyConfig, num_layers: int) -> None:
52
+ """Write the adapter_config.json mlx-lm needs to re-load the adapter."""
53
+ lora = config.training.lora
54
+ config.adapter_path.mkdir(parents=True, exist_ok=True)
55
+ adapter_config = {
56
+ "fine_tune_type": "lora",
57
+ "num_layers": num_layers,
58
+ "lora_parameters": {
59
+ "rank": lora.r,
60
+ "scale": lora.scale,
61
+ "dropout": lora.dropout,
62
+ },
63
+ }
64
+ with open(config.adapter_path / "adapter_config.json", "w") as f:
65
+ json.dump(adapter_config, f, indent=2)
66
+
67
+
68
+ def run_sft(
69
+ config: TroyConfig,
70
+ train_records: List[Dict[str, Any]],
71
+ valid_records: List[Dict[str, Any]],
72
+ ) -> None:
73
+ mx.random.seed(config.training.seed)
74
+ print(f"Loading {config.base} ...")
75
+ model, tokenizer = load(config.base)
76
+
77
+ if not valid_records: # trainer always evaluates; give it something tiny
78
+ valid_records = train_records[:1]
79
+ ds_config = types.SimpleNamespace(mask_prompt=config.data.mask_prompt)
80
+ train_set = create_dataset(train_records, tokenizer, ds_config)
81
+ valid_set = create_dataset(valid_records, tokenizer, ds_config)
82
+
83
+ batch_size = resolve_batch_size(config)
84
+ batch_size = max(1, min(batch_size, len(train_records), len(valid_records)))
85
+ iters = resolve_iters(config, len(train_records), batch_size)
86
+ num_layers = apply_lora(model, config)
87
+ save_adapter_config(config, num_layers)
88
+
89
+ args = TrainingArgs(
90
+ batch_size=batch_size,
91
+ iters=iters,
92
+ max_seq_length=config.training.seq_len,
93
+ adapter_file=str(config.adapter_path / "adapters.safetensors"),
94
+ grad_checkpoint=config.training.grad_checkpoint,
95
+ grad_accumulation_steps=config.training.grad_accumulation_steps,
96
+ steps_per_save=config.training.save_every,
97
+ steps_per_eval=min(200, max(1, iters // 2)),
98
+ val_batches=min(25, max(1, len(valid_records) // batch_size)),
99
+ )
100
+ opt = optim.Adam(learning_rate=config.training.lr)
101
+
102
+ print(
103
+ f"Training: task=sft batch_size={batch_size} iters={iters} "
104
+ f"lr={config.training.lr} seq_len={config.training.seq_len}"
105
+ )
106
+ train(
107
+ model=model,
108
+ optimizer=opt,
109
+ train_dataset=CacheDataset(train_set),
110
+ val_dataset=CacheDataset(valid_set),
111
+ args=args,
112
+ )
113
+ print(f"\nDone. Adapter saved to {config.adapter_path}")
@@ -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,15 @@
1
+ troy/__init__.py,sha256=mCMSFHPxMwZ7O0QyBuh719jLqU_5B2qnFuTtTv6uWC8,86
2
+ troy/chat.py,sha256=SLpGGmsgyV_wMRqx6mzZhrFD5YgyiBm6ezGgYeYKRyk,1693
3
+ troy/cli.py,sha256=yqY3_DeTb8kdB5VAtiLP6Uy_WZIvVA6XbsksBfvBYec,9170
4
+ troy/config.py,sha256=3XOR3dtvkisVuWFJuqx6Cge_plGyz4KcYqwGAcick7U,2160
5
+ troy/data.py,sha256=KGhwB-RuOficxZiDP_vdqItkZ2_7yiKGMqnYm7rKeoM,5382
6
+ troy/export.py,sha256=4k50lcL-CrUcg6f3ibbourpQHr44qj7kkT16SJtqUAs,2424
7
+ troy/hardware.py,sha256=-c5mdZr7SpQk1qo7Eib9nxRSknzE5dxfvyuU18wr-zg,2022
8
+ troy/serve.py,sha256=1Xq3FJ1NlYUo1UD1E7xd2D1ttq46lwIgfpRDjiQ0PmE,1122
9
+ troy/templates.py,sha256=fwfdbG2xKg9kSpT4fKx6poyqpc9eMpW29VXD1wSUaXg,2101
10
+ troy/train_dpo.py,sha256=-Zg53-8rst3mqV5cvr_Yyc9AnSYRP562hDiPMIMBsU4,6126
11
+ troy/train_sft.py,sha256=u3S6WwZrCxwFQfArXu3kNbqXG65L9FZH_J773IK983s,3825
12
+ troy_cli-0.1.2.dist-info/METADATA,sha256=K5OH1etNCT0AfBn9Xv-ZD1x0gQ9N1ZjmCIsWfnV_I4s,3124
13
+ troy_cli-0.1.2.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
14
+ troy_cli-0.1.2.dist-info/entry_points.txt,sha256=jz-AaAmupNovrRxChIqPcfiKm36WkSR8WV2Of0ajlqM,38
15
+ troy_cli-0.1.2.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ troy = troy.cli:app