troy-cli 0.2.2__tar.gz → 0.3.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: troy-cli
3
- Version: 0.2.2
3
+ Version: 0.3.0
4
4
  Summary: Fine-tune LLMs on your MacBook with one YAML file. Built for Apple Silicon.
5
5
  Author: Troy
6
6
  License: Apache-2.0
@@ -11,6 +11,8 @@ Requires-Dist: pydantic>=2.5
11
11
  Requires-Dist: pyyaml>=6.0
12
12
  Requires-Dist: rich>=13.0
13
13
  Requires-Dist: typer>=0.12
14
+ Provides-Extra: vision
15
+ Requires-Dist: mlx-vlm[train]>=0.7; extra == 'vision'
14
16
  Description-Content-Type: text/markdown
15
17
 
16
18
  # Troy
@@ -79,7 +81,7 @@ output: ./output
79
81
  |---|---|
80
82
  | `troy init` | Create a config from a template (`chat`, `dpo`, `orpo`) |
81
83
  | `troy doctor` | Hardware + dependency check, with model-size guidance |
82
- | `troy train` | LoRA fine-tuning: SFT, DPO, or ORPO |
84
+ | `troy train` | LoRA fine-tuning: SFT, DPO, or ORPO — text, or vision with `[vision]` extra |
83
85
  | `troy chat` | Interactive REPL (or `-p` for one-shot) with your adapter |
84
86
  | `troy eval` | Base-vs-tuned val loss, perplexity, side-by-side samples |
85
87
  | `troy serve` | OpenAI-compatible API server for your model |
@@ -64,7 +64,7 @@ output: ./output
64
64
  |---|---|
65
65
  | `troy init` | Create a config from a template (`chat`, `dpo`, `orpo`) |
66
66
  | `troy doctor` | Hardware + dependency check, with model-size guidance |
67
- | `troy train` | LoRA fine-tuning: SFT, DPO, or ORPO |
67
+ | `troy train` | LoRA fine-tuning: SFT, DPO, or ORPO — text, or vision with `[vision]` extra |
68
68
  | `troy chat` | Interactive REPL (or `-p` for one-shot) with your adapter |
69
69
  | `troy eval` | Base-vs-tuned val loss, perplexity, side-by-side samples |
70
70
  | `troy serve` | OpenAI-compatible API server for your model |
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "troy-cli"
3
- version = "0.2.2"
3
+ version = "0.3.0"
4
4
  description = "Fine-tune LLMs on your MacBook with one YAML file. Built for Apple Silicon."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -15,6 +15,9 @@ dependencies = [
15
15
  "rich>=13.0",
16
16
  ]
17
17
 
18
+ [project.optional-dependencies]
19
+ vision = ["mlx-vlm[train]>=0.7"]
20
+
18
21
  [project.scripts]
19
22
  troy = "troy.cli:app"
20
23
 
@@ -1,3 +1,3 @@
1
1
  """Troy: fine-tune LLMs on your MacBook with one YAML file."""
2
2
 
3
- __version__ = "0.2.2"
3
+ __version__ = "0.3.0"
@@ -142,6 +142,19 @@ def train(
142
142
  from .data import load_and_prepare
143
143
 
144
144
  cfg = load_config(config)
145
+
146
+ if Path(cfg.data.train).expanduser().is_dir(): # folder of images => vision
147
+ if cfg.task != "sft":
148
+ console.print("[red]Vision fine-tuning supports task: sft (for now).[/red]")
149
+ raise typer.Exit(1)
150
+ from .train_vision import run_vision_sft
151
+
152
+ run_vision_sft(cfg)
153
+ console.print(
154
+ '\nTry it: [bold]troy chat --image photo.png -p "your question"[/bold]'
155
+ )
156
+ return
157
+
145
158
  train_records, valid_records, fmt = load_and_prepare(
146
159
  cfg.data, cfg.task, cfg.training.seed
147
160
  )
@@ -179,6 +192,7 @@ def chat(
179
192
  max_tokens: int = typer.Option(512),
180
193
  temperature: float = typer.Option(0.7),
181
194
  prompt: Optional[str] = typer.Option(None, "--prompt", "-p", help="One-shot prompt (no REPL)."),
195
+ image: Optional[Path] = typer.Option(None, help="Image for a vision model (one-shot; needs -p)."),
182
196
  ) -> None:
183
197
  """Chat with your fine-tuned model."""
184
198
  _require_apple_silicon()
@@ -198,6 +212,14 @@ def chat(
198
212
  console.print(
199
213
  "[yellow]No trained adapter found — chatting with the base model.[/yellow]"
200
214
  )
215
+ if image is not None:
216
+ if prompt is None:
217
+ console.print("[red]--image needs a one-shot prompt: -p \"your question\"[/red]")
218
+ raise typer.Exit(1)
219
+ from .train_vision import run_vision_chat
220
+
221
+ run_vision_chat(model, adapter, str(image), prompt, max_tokens, temperature)
222
+ return
201
223
  run_chat(model, adapter, max_tokens, temperature, prompt)
202
224
 
203
225
 
@@ -0,0 +1,94 @@
1
+ """Vision-language fine-tuning (wraps mlx-vlm's LoRA trainer).
2
+
3
+ Contract: `data.train` is a FOLDER containing images plus a metadata.jsonl
4
+ with {"file_name", "question", "answer"} rows (HF imagefolder format).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import math
11
+ import subprocess
12
+ import sys
13
+ from pathlib import Path
14
+
15
+ from .config import TroyConfig
16
+
17
+
18
+ def ensure_mlx_vlm() -> None:
19
+ try:
20
+ import mlx_vlm # noqa: F401
21
+ except ImportError:
22
+ raise SystemExit(
23
+ "Vision fine-tuning needs mlx-vlm. Install with:\n"
24
+ " pip install 'troy-cli[vision]'"
25
+ )
26
+
27
+
28
+ def count_records(data_dir: Path) -> int:
29
+ meta = data_dir / "metadata.jsonl"
30
+ if not meta.exists():
31
+ raise SystemExit(
32
+ f"{data_dir} has no metadata.jsonl. Vision data is a folder of images "
33
+ 'plus metadata.jsonl rows: {"file_name": ..., "question": ..., "answer": ...}'
34
+ )
35
+ with open(meta) as f:
36
+ rows = [json.loads(line) for line in f if line.strip()]
37
+ if rows and not {"file_name", "question", "answer"} <= set(rows[0]):
38
+ raise SystemExit(
39
+ "metadata.jsonl rows need file_name, question, and answer fields."
40
+ )
41
+ return len(rows)
42
+
43
+
44
+ def run_vision_sft(config: TroyConfig) -> None:
45
+ ensure_mlx_vlm()
46
+ data_dir = Path(config.data.train).expanduser()
47
+ n = count_records(data_dir)
48
+ print(f"Vision data: {n} images (folder: {data_dir})")
49
+
50
+ t = config.training
51
+ batch_size = 1 if t.batch_size == "auto" else int(t.batch_size)
52
+ iters = t.iters or max(1, math.ceil((t.epochs or 3) * n / batch_size))
53
+
54
+ config.adapter_path.mkdir(parents=True, exist_ok=True)
55
+ cmd = [
56
+ sys.executable, "-m", "mlx_vlm.lora",
57
+ "--model-path", config.base,
58
+ "--dataset", str(data_dir),
59
+ "--split", "train",
60
+ "--batch-size", str(batch_size),
61
+ "--iters", str(iters),
62
+ "--learning-rate", str(t.lr),
63
+ "--lora-rank", str(t.lora.r),
64
+ "--lora-alpha", str(t.lora.alpha),
65
+ "--lora-dropout", str(t.lora.dropout),
66
+ "--max-seq-length", str(t.seq_len),
67
+ "--steps-per-save", str(t.save_every),
68
+ "--output-path", str(config.adapter_path),
69
+ ]
70
+ if t.grad_checkpoint:
71
+ cmd.append("--grad-checkpoint")
72
+
73
+ print(f"Training: task=sft (vision) batch_size={batch_size} iters={iters} lr={t.lr}")
74
+ result = subprocess.run(cmd)
75
+ if result.returncode != 0:
76
+ raise SystemExit(result.returncode)
77
+ print(f"\nDone. Adapter saved to {config.adapter_path}")
78
+
79
+
80
+ def run_vision_chat(
81
+ model: str, adapter: str | None, image: str, prompt: str, max_tokens: int, temperature: float
82
+ ) -> None:
83
+ ensure_mlx_vlm()
84
+ cmd = [
85
+ sys.executable, "-m", "mlx_vlm", "generate",
86
+ "--model", model,
87
+ "--image", image,
88
+ "--prompt", prompt,
89
+ "--max-tokens", str(max_tokens),
90
+ "--temperature", str(temperature),
91
+ ]
92
+ if adapter:
93
+ cmd += ["--adapter-path", adapter]
94
+ raise SystemExit(subprocess.run(cmd).returncode)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes