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.
- troy_cli-0.1.2/.gitignore +6 -0
- troy_cli-0.1.2/PKG-INFO +112 -0
- troy_cli-0.1.2/README.md +97 -0
- troy_cli-0.1.2/pyproject.toml +26 -0
- troy_cli-0.1.2/src/troy/__init__.py +3 -0
- troy_cli-0.1.2/src/troy/chat.py +57 -0
- troy_cli-0.1.2/src/troy/cli.py +274 -0
- troy_cli-0.1.2/src/troy/config.py +82 -0
- troy_cli-0.1.2/src/troy/data.py +163 -0
- troy_cli-0.1.2/src/troy/export.py +81 -0
- troy_cli-0.1.2/src/troy/hardware.py +68 -0
- troy_cli-0.1.2/src/troy/serve.py +45 -0
- troy_cli-0.1.2/src/troy/templates.py +84 -0
- troy_cli-0.1.2/src/troy/train_dpo.py +174 -0
- troy_cli-0.1.2/src/troy/train_sft.py +113 -0
- troy_cli-0.1.2/tests/test_config.py +51 -0
- troy_cli-0.1.2/tests/test_data.py +76 -0
- troy_cli-0.1.2/tests/test_hardware.py +24 -0
troy_cli-0.1.2/PKG-INFO
ADDED
|
@@ -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
|
troy_cli-0.1.2/README.md
ADDED
|
@@ -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,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)
|