chp-adapter-mlx 0.24.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.
- chp_adapter_mlx-0.24.0/.gitignore +16 -0
- chp_adapter_mlx-0.24.0/PKG-INFO +61 -0
- chp_adapter_mlx-0.24.0/README.md +37 -0
- chp_adapter_mlx-0.24.0/chp_adapter_mlx/__init__.py +23 -0
- chp_adapter_mlx-0.24.0/chp_adapter_mlx/adapter.py +806 -0
- chp_adapter_mlx-0.24.0/pyproject.toml +44 -0
- chp_adapter_mlx-0.24.0/tests/test_mlx_adapter.py +305 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: chp-adapter-mlx
|
|
3
|
+
Version: 0.24.0
|
|
4
|
+
Summary: CHP capability adapter — Apple Silicon native text generation via a local mlx_lm server
|
|
5
|
+
Author: Auxo
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Keywords: adapter,apple-silicon,capability-host-protocol,chp,generation,mlx,openai
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Requires-Python: >=3.10
|
|
17
|
+
Requires-Dist: chp-core>=0.7.0
|
|
18
|
+
Provides-Extra: dev
|
|
19
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
20
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
21
|
+
Provides-Extra: serve
|
|
22
|
+
Requires-Dist: mlx-lm>=0.20; extra == 'serve'
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# chp-adapter-mlx
|
|
26
|
+
|
|
27
|
+
Apple Silicon native text generation as governed CHP capabilities, backed by a
|
|
28
|
+
local [`mlx_lm`](https://github.com/ml-explore/mlx-lm) server. MLX is the fastest
|
|
29
|
+
local inference path on Apple Silicon (≈2–3× Ollama, ≈1.5× llama.cpp).
|
|
30
|
+
|
|
31
|
+
`mlx_lm.server` is OpenAI-compatible, so this adapter mirrors `chp-adapter-vllm` /
|
|
32
|
+
`chp-adapter-local-llm` and the gateway routes it as another **inference owner** for
|
|
33
|
+
capacity-aware (GPU-utilization) routing.
|
|
34
|
+
|
|
35
|
+
## Capabilities
|
|
36
|
+
|
|
37
|
+
| Capability | Risk | Description |
|
|
38
|
+
|------------|------|-------------|
|
|
39
|
+
| `chp.adapters.mlx.status` | low | Is `mlx`/`mlx-lm` installed (+ versions) and is the server reachable? |
|
|
40
|
+
| `chp.adapters.mlx.list_models` | low | Models served by `mlx_lm.server` (`/v1/models`) |
|
|
41
|
+
| `chp.adapters.mlx.generate` | medium | Single-turn completion (`/v1/completions`) |
|
|
42
|
+
| `chp.adapters.mlx.chat` | medium | Multi-turn chat (`/v1/chat/completions`) |
|
|
43
|
+
|
|
44
|
+
## Composition & evidence
|
|
45
|
+
|
|
46
|
+
The adapter imports **no HTTP library** — every server call routes through
|
|
47
|
+
`chp.adapters.http`, so HTTP is its own governed evidence chain and the adapter is
|
|
48
|
+
conformance-clean. Prompt/completion/message **content is never recorded in
|
|
49
|
+
evidence**; only model id, token counts, latency, and errors are.
|
|
50
|
+
|
|
51
|
+
## Running the backend (inference node)
|
|
52
|
+
|
|
53
|
+
```sh
|
|
54
|
+
pip install mlx-lm
|
|
55
|
+
# OpenAI-compatible server on :8081 (8080 collides with llama.cpp)
|
|
56
|
+
mlx_lm.server --model mlx-community/Qwen3-... --port 8081
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Config via env: `MLX_BASE_URL` (default `http://localhost:8081`), `MLX_MODEL`,
|
|
60
|
+
`MLX_API_KEY`. Add `"mlx"` to the host profile's `adapters` list to register it.
|
|
61
|
+
Verify over the mesh with `chp.adapters.mlx.status`.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# chp-adapter-mlx
|
|
2
|
+
|
|
3
|
+
Apple Silicon native text generation as governed CHP capabilities, backed by a
|
|
4
|
+
local [`mlx_lm`](https://github.com/ml-explore/mlx-lm) server. MLX is the fastest
|
|
5
|
+
local inference path on Apple Silicon (≈2–3× Ollama, ≈1.5× llama.cpp).
|
|
6
|
+
|
|
7
|
+
`mlx_lm.server` is OpenAI-compatible, so this adapter mirrors `chp-adapter-vllm` /
|
|
8
|
+
`chp-adapter-local-llm` and the gateway routes it as another **inference owner** for
|
|
9
|
+
capacity-aware (GPU-utilization) routing.
|
|
10
|
+
|
|
11
|
+
## Capabilities
|
|
12
|
+
|
|
13
|
+
| Capability | Risk | Description |
|
|
14
|
+
|------------|------|-------------|
|
|
15
|
+
| `chp.adapters.mlx.status` | low | Is `mlx`/`mlx-lm` installed (+ versions) and is the server reachable? |
|
|
16
|
+
| `chp.adapters.mlx.list_models` | low | Models served by `mlx_lm.server` (`/v1/models`) |
|
|
17
|
+
| `chp.adapters.mlx.generate` | medium | Single-turn completion (`/v1/completions`) |
|
|
18
|
+
| `chp.adapters.mlx.chat` | medium | Multi-turn chat (`/v1/chat/completions`) |
|
|
19
|
+
|
|
20
|
+
## Composition & evidence
|
|
21
|
+
|
|
22
|
+
The adapter imports **no HTTP library** — every server call routes through
|
|
23
|
+
`chp.adapters.http`, so HTTP is its own governed evidence chain and the adapter is
|
|
24
|
+
conformance-clean. Prompt/completion/message **content is never recorded in
|
|
25
|
+
evidence**; only model id, token counts, latency, and errors are.
|
|
26
|
+
|
|
27
|
+
## Running the backend (inference node)
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
pip install mlx-lm
|
|
31
|
+
# OpenAI-compatible server on :8081 (8080 collides with llama.cpp)
|
|
32
|
+
mlx_lm.server --model mlx-community/Qwen3-... --port 8081
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Config via env: `MLX_BASE_URL` (default `http://localhost:8081`), `MLX_MODEL`,
|
|
36
|
+
`MLX_API_KEY`. Add `"mlx"` to the host profile's `adapters` list to register it.
|
|
37
|
+
Verify over the mesh with `chp.adapters.mlx.status`.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""chp-adapter-mlx — Apple Silicon native generation as governed CHP capabilities.
|
|
2
|
+
|
|
3
|
+
Composes to a local ``mlx_lm.server`` (Apple's MLX framework — the fastest local
|
|
4
|
+
inference path on Apple Silicon). The server is OpenAI-compatible, so the wire
|
|
5
|
+
shape matches the vLLM/local_llm adapters and the gateway routes MLX as another
|
|
6
|
+
inference owner. Every HTTP call routes through chp.adapters.http — the adapter
|
|
7
|
+
imports no HTTP library.
|
|
8
|
+
|
|
9
|
+
Usage::
|
|
10
|
+
|
|
11
|
+
from chp_core import LocalCapabilityHost, register_adapter
|
|
12
|
+
from chp_adapter_mlx import MLXAdapter, MLXConfig
|
|
13
|
+
|
|
14
|
+
host = LocalCapabilityHost()
|
|
15
|
+
register_adapter(host, MLXAdapter(MLXConfig(base_url="http://localhost:8081")))
|
|
16
|
+
result = host.invoke("chp.adapters.mlx.status", {}) # is MLX installed + serving?
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from .adapter import MLXAdapter, MLXConfig
|
|
22
|
+
|
|
23
|
+
__all__ = ["MLXAdapter", "MLXConfig"]
|
|
@@ -0,0 +1,806 @@
|
|
|
1
|
+
"""MLXAdapter — Apple Silicon native text generation via a local MLX server.
|
|
2
|
+
|
|
3
|
+
Wraps a local ``mlx_lm.server`` (Apple's MLX framework — the fastest local
|
|
4
|
+
inference path on Apple Silicon) as governed CHP capabilities: generate, chat,
|
|
5
|
+
list_models, and status. ``mlx_lm.server`` exposes an OpenAI-compatible API
|
|
6
|
+
(``/v1/completions``, ``/v1/chat/completions``, ``/v1/models``), so the wire shape
|
|
7
|
+
matches the vLLM/local_llm adapters and the gateway can treat MLX as just another
|
|
8
|
+
inference owner for capacity-aware routing.
|
|
9
|
+
|
|
10
|
+
Lego-block composition: this adapter imports NO HTTP library. Every server call
|
|
11
|
+
routes through chp.adapters.http via ctx.ainvoke, so HTTP is its own governed
|
|
12
|
+
evidence chain and the adapter stays conformance-clean. The ``status`` capability
|
|
13
|
+
additionally reports whether the ``mlx`` / ``mlx-lm`` packages are installed on the
|
|
14
|
+
host (via importlib, no heavy import) — "is MLX on this machine and serving?"
|
|
15
|
+
|
|
16
|
+
Evidence policy:
|
|
17
|
+
Emitted: model id, prompt/completion token counts, message count, latency, errors.
|
|
18
|
+
NOT emitted: prompt text, completion text, or chat message content.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import contextlib
|
|
24
|
+
import glob
|
|
25
|
+
import importlib.util
|
|
26
|
+
import json
|
|
27
|
+
import os
|
|
28
|
+
import re
|
|
29
|
+
import signal
|
|
30
|
+
import subprocess
|
|
31
|
+
import sys
|
|
32
|
+
import time
|
|
33
|
+
from collections import Counter
|
|
34
|
+
from dataclasses import dataclass
|
|
35
|
+
from typing import Any
|
|
36
|
+
|
|
37
|
+
from chp_core import BaseAdapter, capability
|
|
38
|
+
|
|
39
|
+
_EMITS = [
|
|
40
|
+
"mlx_generate_started",
|
|
41
|
+
"mlx_generate_completed",
|
|
42
|
+
"mlx_generate_failed",
|
|
43
|
+
"mlx_chat_started",
|
|
44
|
+
"mlx_chat_completed",
|
|
45
|
+
"mlx_chat_failed",
|
|
46
|
+
"mlx_models_listed",
|
|
47
|
+
"mlx_status_reported",
|
|
48
|
+
"mlx_finetune_started",
|
|
49
|
+
"mlx_eval_started",
|
|
50
|
+
"mlx_eval_completed",
|
|
51
|
+
"mlx_eval_failed",
|
|
52
|
+
"mlx_server_started",
|
|
53
|
+
"mlx_server_stopped",
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
# mlx_lm.server defaults to :8080, which collides with llama.cpp (probed by the
|
|
57
|
+
# local_llm adapter). Default MLX to :8081 and run `mlx_lm.server --port 8081`.
|
|
58
|
+
_DEFAULT_BASE_URL = "http://localhost:8081"
|
|
59
|
+
_HTTP_CAP = "chp.adapters.http.request"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _service_safe_env() -> dict[str, str]:
|
|
63
|
+
"""Child env safe under launchd/systemd: ensure HOME (HF cache + logs) and a
|
|
64
|
+
full PATH. Mirrors the host adapter's helper."""
|
|
65
|
+
import pwd
|
|
66
|
+
env = dict(os.environ)
|
|
67
|
+
if not env.get("HOME"):
|
|
68
|
+
try:
|
|
69
|
+
env["HOME"] = pwd.getpwuid(os.getuid()).pw_dir
|
|
70
|
+
except Exception:
|
|
71
|
+
env["HOME"] = "/tmp"
|
|
72
|
+
env["PATH"] = (env.get("PATH", "") + ":/usr/sbin:/usr/bin:/sbin:/bin:/usr/local/bin:/opt/homebrew/bin").strip(":")
|
|
73
|
+
return env
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _run_dir() -> str:
|
|
77
|
+
d = os.path.join(_service_safe_env()["HOME"], ".chp", "run")
|
|
78
|
+
os.makedirs(d, exist_ok=True)
|
|
79
|
+
return d
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _read_pid(path: str) -> int | None:
|
|
83
|
+
# os.open (not open()) keeps the adapter conformance-clean.
|
|
84
|
+
try:
|
|
85
|
+
fd = os.open(path, os.O_RDONLY)
|
|
86
|
+
try:
|
|
87
|
+
data = os.read(fd, 32).decode().strip()
|
|
88
|
+
finally:
|
|
89
|
+
os.close(fd)
|
|
90
|
+
return int(data) if data else None
|
|
91
|
+
except (OSError, ValueError):
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _write_pid(path: str, pid: int) -> None:
|
|
96
|
+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o644)
|
|
97
|
+
try:
|
|
98
|
+
os.write(fd, str(pid).encode())
|
|
99
|
+
finally:
|
|
100
|
+
os.close(fd)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _alive(pid: int) -> bool:
|
|
104
|
+
try:
|
|
105
|
+
os.kill(pid, 0)
|
|
106
|
+
return True
|
|
107
|
+
except OSError:
|
|
108
|
+
return False
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _server_cmd(model: str, port: int, host: str, adapter_path: str | None = None) -> list[str]:
|
|
112
|
+
"""Command to launch mlx_lm's OpenAI server. Prefer the console script next to
|
|
113
|
+
the interpreter; fall back to `python -m mlx_lm.server`. --adapter-path serves a
|
|
114
|
+
LoRA on top of the base model (the flywheel's tuned variant)."""
|
|
115
|
+
script = os.path.join(os.path.dirname(sys.executable), "mlx_lm.server")
|
|
116
|
+
base = [script] if os.path.exists(script) else [sys.executable, "-m", "mlx_lm.server"]
|
|
117
|
+
cmd = base + ["--model", model, "--port", str(port), "--host", host]
|
|
118
|
+
if adapter_path:
|
|
119
|
+
cmd += ["--adapter-path", adapter_path]
|
|
120
|
+
return cmd
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _lora_cmd(model: str, data: str, adapter_path: str, iters: int,
|
|
124
|
+
batch_size: int, num_layers: int | None) -> list[str]:
|
|
125
|
+
"""`mlx_lm.lora --train` command — LoRA fine-tune on-fleet (the flywheel's C3).
|
|
126
|
+
*data* is a dir with train.jsonl / valid.jsonl (chat or completions format)."""
|
|
127
|
+
script = os.path.join(os.path.dirname(sys.executable), "mlx_lm.lora")
|
|
128
|
+
base = [script] if os.path.exists(script) else [sys.executable, "-m", "mlx_lm.lora"]
|
|
129
|
+
cmd = base + ["--model", model, "--train", "--data", data, "--adapter-path", adapter_path,
|
|
130
|
+
"--iters", str(iters), "--batch-size", str(batch_size)]
|
|
131
|
+
if num_layers:
|
|
132
|
+
cmd += ["--num-layers", str(num_layers)]
|
|
133
|
+
return cmd
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _jsonl(content: Any) -> str:
|
|
137
|
+
"""Coerce inline dataset content to JSONL text: pass a JSONL string through, or
|
|
138
|
+
serialize a list of records (dicts)."""
|
|
139
|
+
if isinstance(content, str):
|
|
140
|
+
return content if content.endswith("\n") else content + "\n"
|
|
141
|
+
return "".join(json.dumps(r) + "\n" for r in (content or []))
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _materialize_dataset(name: str, train: Any, valid: Any) -> str:
|
|
145
|
+
"""Write inline train/valid content into a fresh data dir *on this node* and
|
|
146
|
+
return its path. The finetune node has no process.run and its filesystem
|
|
147
|
+
adapter won't mkdir — but this adapter is Python on that node, so it creates
|
|
148
|
+
its own dataset dir. Solves the cross-node data problem (data + compute must be
|
|
149
|
+
co-located; capacity routing can split them across hosts)."""
|
|
150
|
+
base = os.path.join(_service_safe_env()["HOME"], ".chp", "flywheel-data", name)
|
|
151
|
+
os.makedirs(base, exist_ok=True)
|
|
152
|
+
for fname, content in (("train.jsonl", train), ("valid.jsonl", valid)):
|
|
153
|
+
if content is None:
|
|
154
|
+
continue
|
|
155
|
+
with open(os.path.join(base, fname), "w") as f:
|
|
156
|
+
f.write(_jsonl(content))
|
|
157
|
+
return base
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _stop_all_mlx_servers() -> list[dict]:
|
|
161
|
+
"""SIGTERM every tracked mlx_lm server and clear its pidfile — free the GPU
|
|
162
|
+
before training. A served model and LoRA training cannot share a single-GPU
|
|
163
|
+
Apple Silicon node (Metal OOMs after the first iter). Returns what was stopped
|
|
164
|
+
so the caller can re-serve (e.g. with the freshly tuned adapter)."""
|
|
165
|
+
stopped: list[dict] = []
|
|
166
|
+
for pidfile in glob.glob(os.path.join(_run_dir(), "mlx-server-*.pid")):
|
|
167
|
+
pid = _read_pid(pidfile)
|
|
168
|
+
port = os.path.basename(pidfile)[len("mlx-server-"):-len(".pid")]
|
|
169
|
+
if pid and _alive(pid):
|
|
170
|
+
with contextlib.suppress(OSError):
|
|
171
|
+
os.kill(pid, signal.SIGTERM)
|
|
172
|
+
stopped.append({"pid": pid, "port": port})
|
|
173
|
+
with contextlib.suppress(OSError):
|
|
174
|
+
os.remove(pidfile)
|
|
175
|
+
return stopped
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _tokens(s: str) -> list[str]:
|
|
179
|
+
return re.findall(r"[a-z0-9]+", (s or "").lower())
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _score_one(completion: str, reference: str, metric: str) -> float:
|
|
183
|
+
"""Deterministic score of a completion against a reference in [0,1]. `contains`
|
|
184
|
+
= reference substring present; `f1` = token-overlap F1. Text is scored on-node and
|
|
185
|
+
never recorded — only the resulting score lands in evidence."""
|
|
186
|
+
if metric == "contains":
|
|
187
|
+
ref = (reference or "").strip().lower()
|
|
188
|
+
return 1.0 if ref and ref in (completion or "").lower() else 0.0
|
|
189
|
+
c, r = _tokens(completion), _tokens(reference)
|
|
190
|
+
if not c or not r:
|
|
191
|
+
return 0.0
|
|
192
|
+
overlap = sum((Counter(c) & Counter(r)).values())
|
|
193
|
+
if not overlap:
|
|
194
|
+
return 0.0
|
|
195
|
+
prec, rec = overlap / len(c), overlap / len(r)
|
|
196
|
+
return round(2 * prec * rec / (prec + rec), 4)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _pkg_version(name: str) -> str | None:
|
|
200
|
+
"""Installed version of *name*, or None — without importing the package."""
|
|
201
|
+
try:
|
|
202
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
203
|
+
try:
|
|
204
|
+
return version(name)
|
|
205
|
+
except PackageNotFoundError:
|
|
206
|
+
return None
|
|
207
|
+
except Exception:
|
|
208
|
+
return None
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
@dataclass
|
|
212
|
+
class MLXConfig:
|
|
213
|
+
base_url: str = ""
|
|
214
|
+
api_key: str = ""
|
|
215
|
+
default_model: str = ""
|
|
216
|
+
timeout: float = 120.0
|
|
217
|
+
|
|
218
|
+
def resolved_base_url(self) -> str:
|
|
219
|
+
return self.base_url or os.environ.get("MLX_BASE_URL", _DEFAULT_BASE_URL)
|
|
220
|
+
|
|
221
|
+
def resolved_api_key(self) -> str:
|
|
222
|
+
# mlx_lm.server accepts any key; allow override for secured deployments.
|
|
223
|
+
return self.api_key or os.environ.get("MLX_API_KEY", "EMPTY")
|
|
224
|
+
|
|
225
|
+
def resolved_default_model(self) -> str:
|
|
226
|
+
return self.default_model or os.environ.get("MLX_MODEL", "")
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
class MLXAdapter(BaseAdapter):
|
|
230
|
+
"""Apple Silicon native generation via a local mlx_lm OpenAI-compatible server."""
|
|
231
|
+
|
|
232
|
+
adapter_id = "chp.adapters.mlx"
|
|
233
|
+
adapter_name = "MLX"
|
|
234
|
+
adapter_description = (
|
|
235
|
+
"Text generation and chat from a local mlx_lm.server (Apple Silicon / MLX), "
|
|
236
|
+
"composed through chp.adapters.http as governed CHP capabilities."
|
|
237
|
+
)
|
|
238
|
+
adapter_category = "ai"
|
|
239
|
+
adapter_tags = ["mlx", "generation", "chat", "metal", "apple-silicon", "openai", "local"]
|
|
240
|
+
|
|
241
|
+
def __init__(self, config: MLXConfig | None = None) -> None:
|
|
242
|
+
self._config = config or MLXConfig()
|
|
243
|
+
|
|
244
|
+
# ------------------------------------------------------------------
|
|
245
|
+
# HTTP composition through the multi-capability router
|
|
246
|
+
# ------------------------------------------------------------------
|
|
247
|
+
|
|
248
|
+
async def _http(self, ctx: Any, method: str, path: str, json_body: Any | None = None) -> dict:
|
|
249
|
+
base = self._config.resolved_base_url().rstrip("/")
|
|
250
|
+
req: dict[str, Any] = {"method": method, "url": f"{base}{path}", "timeout": self._config.timeout}
|
|
251
|
+
if json_body is not None:
|
|
252
|
+
req["json_body"] = json_body
|
|
253
|
+
api_key = self._config.resolved_api_key()
|
|
254
|
+
if api_key:
|
|
255
|
+
req["headers"] = {"Authorization": f"Bearer {api_key}"}
|
|
256
|
+
|
|
257
|
+
result = await ctx.ainvoke(_HTTP_CAP, req)
|
|
258
|
+
if not result.success:
|
|
259
|
+
raise RuntimeError(
|
|
260
|
+
f"MLX {method} {path}: http adapter unavailable or denied "
|
|
261
|
+
f"({getattr(result, 'error', 'unknown error')}). "
|
|
262
|
+
"Ensure chp.adapters.http is registered on this host."
|
|
263
|
+
)
|
|
264
|
+
data = result.data
|
|
265
|
+
status = data.get("status_code")
|
|
266
|
+
if status is None or status >= 400:
|
|
267
|
+
raise RuntimeError(f"MLX {method} {path} returned HTTP {status}")
|
|
268
|
+
return data
|
|
269
|
+
|
|
270
|
+
def _model(self, payload: dict) -> str:
|
|
271
|
+
model = payload.get("model") or self._config.resolved_default_model()
|
|
272
|
+
if not model:
|
|
273
|
+
raise ValueError("No model specified and no default_model configured (set MLX_MODEL).")
|
|
274
|
+
return model
|
|
275
|
+
|
|
276
|
+
# ------------------------------------------------------------------
|
|
277
|
+
# generate
|
|
278
|
+
# ------------------------------------------------------------------
|
|
279
|
+
|
|
280
|
+
@capability(
|
|
281
|
+
id="chp.adapters.mlx.generate",
|
|
282
|
+
version="1.0.0",
|
|
283
|
+
description=(
|
|
284
|
+
"Single-turn text completion via a local mlx_lm server (OpenAI /v1/completions), "
|
|
285
|
+
"composed through chp.adapters.http. Prompt and completion text are never recorded in evidence."
|
|
286
|
+
),
|
|
287
|
+
category="ai",
|
|
288
|
+
provider="mlx",
|
|
289
|
+
risk="medium",
|
|
290
|
+
side_effects=["llm_inference"],
|
|
291
|
+
emits=_EMITS,
|
|
292
|
+
input_schema={
|
|
293
|
+
"type": "object",
|
|
294
|
+
"properties": {
|
|
295
|
+
"model": {"type": "string", "description": "Model id served by mlx_lm (defaults to configured model)"},
|
|
296
|
+
"prompt": {"type": "string", "minLength": 1},
|
|
297
|
+
"max_tokens": {"type": "integer", "minimum": 1, "maximum": 8192, "default": 256},
|
|
298
|
+
"temperature": {"type": "number", "minimum": 0.0, "maximum": 2.0, "default": 0.7},
|
|
299
|
+
"top_p": {"type": "number", "minimum": 0.0, "maximum": 1.0},
|
|
300
|
+
"stop": {"type": "array", "items": {"type": "string"}},
|
|
301
|
+
},
|
|
302
|
+
"required": ["prompt"],
|
|
303
|
+
"additionalProperties": False,
|
|
304
|
+
},
|
|
305
|
+
)
|
|
306
|
+
async def generate(self, ctx: Any, payload: dict) -> dict:
|
|
307
|
+
model = self._model(payload)
|
|
308
|
+
body: dict[str, Any] = {
|
|
309
|
+
"model": model,
|
|
310
|
+
"prompt": payload["prompt"],
|
|
311
|
+
"max_tokens": payload.get("max_tokens", 256),
|
|
312
|
+
"temperature": payload.get("temperature", 0.7),
|
|
313
|
+
}
|
|
314
|
+
if "top_p" in payload:
|
|
315
|
+
body["top_p"] = payload["top_p"]
|
|
316
|
+
if payload.get("stop"):
|
|
317
|
+
body["stop"] = payload["stop"]
|
|
318
|
+
|
|
319
|
+
ctx.emit("mlx_generate_started", {"model": model, "max_tokens": body["max_tokens"]}, redacted=False)
|
|
320
|
+
|
|
321
|
+
t0 = time.monotonic()
|
|
322
|
+
try:
|
|
323
|
+
data = await self._http(ctx, "POST", "/v1/completions", body)
|
|
324
|
+
except Exception as exc:
|
|
325
|
+
ctx.emit("mlx_generate_failed", {"model": model, "error": str(exc)[:500]}, redacted=False)
|
|
326
|
+
raise
|
|
327
|
+
|
|
328
|
+
resp = data.get("json") or {}
|
|
329
|
+
choice = (resp.get("choices") or [{}])[0]
|
|
330
|
+
usage = resp.get("usage") or {}
|
|
331
|
+
latency_ms = round((time.monotonic() - t0) * 1000)
|
|
332
|
+
prompt_tokens = usage.get("prompt_tokens", 0)
|
|
333
|
+
completion_tokens = usage.get("completion_tokens", 0)
|
|
334
|
+
|
|
335
|
+
ctx.emit("mlx_generate_completed", {
|
|
336
|
+
"model": model,
|
|
337
|
+
"prompt_tokens": prompt_tokens,
|
|
338
|
+
"completion_tokens": completion_tokens,
|
|
339
|
+
"finish_reason": choice.get("finish_reason"),
|
|
340
|
+
"latency_ms": latency_ms,
|
|
341
|
+
}, redacted=False)
|
|
342
|
+
|
|
343
|
+
return {
|
|
344
|
+
"model": model,
|
|
345
|
+
"text": choice.get("text", ""),
|
|
346
|
+
"finish_reason": choice.get("finish_reason"),
|
|
347
|
+
"prompt_tokens": prompt_tokens,
|
|
348
|
+
"completion_tokens": completion_tokens,
|
|
349
|
+
"latency_ms": latency_ms,
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
# ------------------------------------------------------------------
|
|
353
|
+
# chat
|
|
354
|
+
# ------------------------------------------------------------------
|
|
355
|
+
|
|
356
|
+
@capability(
|
|
357
|
+
id="chp.adapters.mlx.chat",
|
|
358
|
+
version="1.0.0",
|
|
359
|
+
description=(
|
|
360
|
+
"Multi-turn chat via a local mlx_lm server (OpenAI /v1/chat/completions), composed "
|
|
361
|
+
"through chp.adapters.http. Message content is never recorded in evidence."
|
|
362
|
+
),
|
|
363
|
+
category="ai",
|
|
364
|
+
provider="mlx",
|
|
365
|
+
risk="medium",
|
|
366
|
+
side_effects=["llm_inference"],
|
|
367
|
+
emits=_EMITS,
|
|
368
|
+
input_schema={
|
|
369
|
+
"type": "object",
|
|
370
|
+
"properties": {
|
|
371
|
+
"model": {"type": "string"},
|
|
372
|
+
"messages": {
|
|
373
|
+
"type": "array",
|
|
374
|
+
"items": {
|
|
375
|
+
# Permissive to support OpenAI tool-calling turns: 'tool' role
|
|
376
|
+
# (tool results), assistant messages with tool_calls (content
|
|
377
|
+
# null), tool_call_id/name. Forwarded verbatim to the server.
|
|
378
|
+
"type": "object",
|
|
379
|
+
"properties": {
|
|
380
|
+
"role": {"type": "string", "enum": ["system", "user", "assistant", "tool"]},
|
|
381
|
+
"content": {"type": ["string", "null"]},
|
|
382
|
+
},
|
|
383
|
+
"required": ["role"],
|
|
384
|
+
"additionalProperties": True,
|
|
385
|
+
},
|
|
386
|
+
"minItems": 1,
|
|
387
|
+
},
|
|
388
|
+
"max_tokens": {"type": "integer", "minimum": 1, "maximum": 8192, "default": 256},
|
|
389
|
+
"temperature": {"type": "number", "minimum": 0.0, "maximum": 2.0, "default": 0.7},
|
|
390
|
+
"top_p": {"type": "number", "minimum": 0.0, "maximum": 1.0},
|
|
391
|
+
"tools": {"type": "array", "items": {"type": "object"},
|
|
392
|
+
"description": "OpenAI-format tool definitions to forward to the server (enables tool-calling through the mesh; never recorded in evidence)."},
|
|
393
|
+
"tool_choice": {"description": "OpenAI tool_choice: 'auto' | 'none' | 'required' | {type, function}."},
|
|
394
|
+
},
|
|
395
|
+
"required": ["messages"],
|
|
396
|
+
"additionalProperties": False,
|
|
397
|
+
},
|
|
398
|
+
)
|
|
399
|
+
async def chat(self, ctx: Any, payload: dict) -> dict:
|
|
400
|
+
model = self._model(payload)
|
|
401
|
+
messages: list[dict] = payload["messages"]
|
|
402
|
+
body: dict[str, Any] = {
|
|
403
|
+
"model": model,
|
|
404
|
+
"messages": messages,
|
|
405
|
+
"max_tokens": payload.get("max_tokens", 256),
|
|
406
|
+
"temperature": payload.get("temperature", 0.7),
|
|
407
|
+
}
|
|
408
|
+
if "top_p" in payload:
|
|
409
|
+
body["top_p"] = payload["top_p"]
|
|
410
|
+
if payload.get("tools"):
|
|
411
|
+
body["tools"] = payload["tools"]
|
|
412
|
+
if payload.get("tool_choice") is not None:
|
|
413
|
+
body["tool_choice"] = payload["tool_choice"]
|
|
414
|
+
|
|
415
|
+
ctx.emit("mlx_chat_started", {
|
|
416
|
+
"model": model, "message_count": len(messages),
|
|
417
|
+
"tool_count": len(payload.get("tools") or []),
|
|
418
|
+
}, redacted=False)
|
|
419
|
+
|
|
420
|
+
t0 = time.monotonic()
|
|
421
|
+
try:
|
|
422
|
+
data = await self._http(ctx, "POST", "/v1/chat/completions", body)
|
|
423
|
+
except Exception as exc:
|
|
424
|
+
ctx.emit("mlx_chat_failed", {"model": model, "error": str(exc)[:500]}, redacted=False)
|
|
425
|
+
raise
|
|
426
|
+
|
|
427
|
+
resp = data.get("json") or {}
|
|
428
|
+
choice = (resp.get("choices") or [{}])[0]
|
|
429
|
+
usage = resp.get("usage") or {}
|
|
430
|
+
latency_ms = round((time.monotonic() - t0) * 1000)
|
|
431
|
+
prompt_tokens = usage.get("prompt_tokens", 0)
|
|
432
|
+
completion_tokens = usage.get("completion_tokens", 0)
|
|
433
|
+
|
|
434
|
+
ctx.emit("mlx_chat_completed", {
|
|
435
|
+
"model": model,
|
|
436
|
+
"message_count": len(messages),
|
|
437
|
+
"prompt_tokens": prompt_tokens,
|
|
438
|
+
"completion_tokens": completion_tokens,
|
|
439
|
+
"finish_reason": choice.get("finish_reason"),
|
|
440
|
+
"latency_ms": latency_ms,
|
|
441
|
+
}, redacted=False)
|
|
442
|
+
|
|
443
|
+
return {
|
|
444
|
+
"model": model,
|
|
445
|
+
"message": choice.get("message", {}),
|
|
446
|
+
"finish_reason": choice.get("finish_reason"),
|
|
447
|
+
"prompt_tokens": prompt_tokens,
|
|
448
|
+
"completion_tokens": completion_tokens,
|
|
449
|
+
"latency_ms": latency_ms,
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
# ------------------------------------------------------------------
|
|
453
|
+
# list_models
|
|
454
|
+
# ------------------------------------------------------------------
|
|
455
|
+
|
|
456
|
+
@capability(
|
|
457
|
+
id="chp.adapters.mlx.list_models",
|
|
458
|
+
version="1.0.0",
|
|
459
|
+
description="List models served by the local mlx_lm server (OpenAI /v1/models), via chp.adapters.http.",
|
|
460
|
+
category="ai",
|
|
461
|
+
provider="mlx",
|
|
462
|
+
risk="low",
|
|
463
|
+
emits=_EMITS,
|
|
464
|
+
input_schema={"type": "object", "properties": {}, "additionalProperties": False},
|
|
465
|
+
)
|
|
466
|
+
async def list_models(self, ctx: Any, payload: dict) -> dict:
|
|
467
|
+
t0 = time.monotonic()
|
|
468
|
+
data = await self._http(ctx, "GET", "/v1/models")
|
|
469
|
+
latency_ms = round((time.monotonic() - t0) * 1000)
|
|
470
|
+
|
|
471
|
+
resp = data.get("json") or {}
|
|
472
|
+
models = [{"id": m.get("id"), "owned_by": m.get("owned_by")} for m in (resp.get("data") or [])]
|
|
473
|
+
ctx.emit("mlx_models_listed", {"model_count": len(models), "latency_ms": latency_ms}, redacted=False)
|
|
474
|
+
return {"models": models, "model_count": len(models), "latency_ms": latency_ms}
|
|
475
|
+
|
|
476
|
+
# ------------------------------------------------------------------
|
|
477
|
+
# status — "is MLX on this machine and serving?"
|
|
478
|
+
# ------------------------------------------------------------------
|
|
479
|
+
|
|
480
|
+
@capability(
|
|
481
|
+
id="chp.adapters.mlx.status",
|
|
482
|
+
version="1.0.0",
|
|
483
|
+
description=(
|
|
484
|
+
"Report MLX availability on this host: whether the mlx / mlx-lm packages are "
|
|
485
|
+
"installed (and their versions) and whether the local mlx_lm server is reachable. "
|
|
486
|
+
"Low-risk introspection — makes no inference."
|
|
487
|
+
),
|
|
488
|
+
category="ai",
|
|
489
|
+
provider="mlx",
|
|
490
|
+
risk="low",
|
|
491
|
+
emits=_EMITS,
|
|
492
|
+
input_schema={"type": "object", "properties": {}, "additionalProperties": False},
|
|
493
|
+
)
|
|
494
|
+
async def status(self, ctx: Any, payload: dict) -> dict:
|
|
495
|
+
# Package availability — importlib.util.find_spec does not import the package.
|
|
496
|
+
mlx_installed = importlib.util.find_spec("mlx") is not None
|
|
497
|
+
mlx_lm_installed = importlib.util.find_spec("mlx_lm") is not None
|
|
498
|
+
base_url = self._config.resolved_base_url()
|
|
499
|
+
|
|
500
|
+
server_healthy = False
|
|
501
|
+
model_count = 0
|
|
502
|
+
models: list[dict] = []
|
|
503
|
+
latency_ms: int | None = None
|
|
504
|
+
server_error: str | None = None
|
|
505
|
+
|
|
506
|
+
t0 = time.monotonic()
|
|
507
|
+
try:
|
|
508
|
+
data = await self._http(ctx, "GET", "/v1/models")
|
|
509
|
+
latency_ms = round((time.monotonic() - t0) * 1000)
|
|
510
|
+
resp = data.get("json") or {}
|
|
511
|
+
models = [{"id": m.get("id"), "owned_by": m.get("owned_by")} for m in (resp.get("data") or [])]
|
|
512
|
+
model_count = len(models)
|
|
513
|
+
server_healthy = True
|
|
514
|
+
except Exception as exc:
|
|
515
|
+
server_error = str(exc)[:300]
|
|
516
|
+
|
|
517
|
+
result = {
|
|
518
|
+
"mlx_installed": mlx_installed,
|
|
519
|
+
"mlx_version": _pkg_version("mlx"),
|
|
520
|
+
"mlx_lm_installed": mlx_lm_installed,
|
|
521
|
+
"mlx_lm_version": _pkg_version("mlx-lm"),
|
|
522
|
+
"server_url": base_url,
|
|
523
|
+
"server_healthy": server_healthy,
|
|
524
|
+
"model_count": model_count,
|
|
525
|
+
"models": models,
|
|
526
|
+
"default_model": self._config.resolved_default_model() or None,
|
|
527
|
+
"latency_ms": latency_ms,
|
|
528
|
+
"server_error": server_error,
|
|
529
|
+
}
|
|
530
|
+
ctx.emit("mlx_status_reported", {
|
|
531
|
+
"mlx_installed": mlx_installed,
|
|
532
|
+
"mlx_lm_installed": mlx_lm_installed,
|
|
533
|
+
"server_healthy": server_healthy,
|
|
534
|
+
"model_count": model_count,
|
|
535
|
+
}, redacted=False)
|
|
536
|
+
return result
|
|
537
|
+
|
|
538
|
+
# ------------------------------------------------------------------
|
|
539
|
+
# start_server / stop_server — manage the mlx_lm inference server
|
|
540
|
+
# ------------------------------------------------------------------
|
|
541
|
+
|
|
542
|
+
@capability(
|
|
543
|
+
id="chp.adapters.mlx.start_server",
|
|
544
|
+
version="1.0.0",
|
|
545
|
+
description=(
|
|
546
|
+
"Start a local mlx_lm OpenAI server for a model, detached (survives this "
|
|
547
|
+
"host), logging to ~/.chp/logs/mlx-server-<port>.log. The model downloads "
|
|
548
|
+
"on first load. Idempotent: a server already running on the port is left as-is."
|
|
549
|
+
),
|
|
550
|
+
category="ai",
|
|
551
|
+
provider="mlx",
|
|
552
|
+
risk="high",
|
|
553
|
+
side_effects=["process_spawn"],
|
|
554
|
+
emits=_EMITS,
|
|
555
|
+
input_schema={
|
|
556
|
+
"type": "object",
|
|
557
|
+
"properties": {
|
|
558
|
+
"model": {"type": "string", "description": "MLX model repo (defaults to MLX_MODEL)."},
|
|
559
|
+
"port": {"type": "integer", "minimum": 1, "maximum": 65535, "default": 8081},
|
|
560
|
+
"host": {"type": "string", "default": "127.0.0.1"},
|
|
561
|
+
"adapter_path": {"type": "string", "description": "Serve a LoRA adapter on top of the base model (the flywheel's tuned variant)."},
|
|
562
|
+
},
|
|
563
|
+
"additionalProperties": False,
|
|
564
|
+
},
|
|
565
|
+
)
|
|
566
|
+
async def start_server(self, ctx: Any, payload: dict) -> dict:
|
|
567
|
+
model = payload.get("model") or self._config.resolved_default_model()
|
|
568
|
+
if not model:
|
|
569
|
+
raise ValueError("No model specified and no MLX_MODEL configured.")
|
|
570
|
+
# Remember the served model as this adapter's default, so subsequent
|
|
571
|
+
# generate/chat calls need not repeat it (for the life of this process).
|
|
572
|
+
self._config.default_model = model
|
|
573
|
+
port = int(payload.get("port") or 8081)
|
|
574
|
+
host = str(payload.get("host") or "127.0.0.1")
|
|
575
|
+
adapter_path = payload.get("adapter_path") or None
|
|
576
|
+
pidfile = os.path.join(_run_dir(), f"mlx-server-{port}.pid")
|
|
577
|
+
|
|
578
|
+
existing = _read_pid(pidfile)
|
|
579
|
+
if existing and _alive(existing):
|
|
580
|
+
ctx.emit("mlx_server_started", {"port": port, "pid": existing, "already_running": True}, redacted=False)
|
|
581
|
+
return {"started": False, "already_running": True, "pid": existing, "port": port, "model": model}
|
|
582
|
+
|
|
583
|
+
env = _service_safe_env()
|
|
584
|
+
log_dir = os.path.join(env["HOME"], ".chp", "logs")
|
|
585
|
+
os.makedirs(log_dir, exist_ok=True)
|
|
586
|
+
fd = os.open(os.path.join(log_dir, f"mlx-server-{port}.log"),
|
|
587
|
+
os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o644)
|
|
588
|
+
try:
|
|
589
|
+
proc = subprocess.Popen(_server_cmd(model, port, host, adapter_path),
|
|
590
|
+
stdout=fd, stderr=fd, start_new_session=True, env=env)
|
|
591
|
+
finally:
|
|
592
|
+
os.close(fd)
|
|
593
|
+
_write_pid(pidfile, proc.pid)
|
|
594
|
+
ctx.emit("mlx_server_started", {"model": model, "port": port, "pid": proc.pid}, redacted=False)
|
|
595
|
+
return {
|
|
596
|
+
"started": True,
|
|
597
|
+
"pid": proc.pid,
|
|
598
|
+
"port": port,
|
|
599
|
+
"model": model,
|
|
600
|
+
"note": "Loads weights (downloads on first run) then serves; poll chp.adapters.mlx.status.",
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
@capability(
|
|
604
|
+
id="chp.adapters.mlx.stop_server",
|
|
605
|
+
version="1.0.0",
|
|
606
|
+
description="Stop the local mlx_lm server running on a port (SIGTERM the tracked pid).",
|
|
607
|
+
category="ai",
|
|
608
|
+
provider="mlx",
|
|
609
|
+
risk="high",
|
|
610
|
+
side_effects=["process_kill"],
|
|
611
|
+
emits=_EMITS,
|
|
612
|
+
input_schema={
|
|
613
|
+
"type": "object",
|
|
614
|
+
"properties": {"port": {"type": "integer", "minimum": 1, "maximum": 65535, "default": 8081}},
|
|
615
|
+
"additionalProperties": False,
|
|
616
|
+
},
|
|
617
|
+
)
|
|
618
|
+
async def stop_server(self, ctx: Any, payload: dict) -> dict:
|
|
619
|
+
port = int(payload.get("port") or 8081)
|
|
620
|
+
pidfile = os.path.join(_run_dir(), f"mlx-server-{port}.pid")
|
|
621
|
+
pid = _read_pid(pidfile)
|
|
622
|
+
if not pid or not _alive(pid):
|
|
623
|
+
return {"stopped": False, "running": False, "port": port}
|
|
624
|
+
os.kill(pid, signal.SIGTERM)
|
|
625
|
+
with contextlib.suppress(OSError):
|
|
626
|
+
os.remove(pidfile)
|
|
627
|
+
ctx.emit("mlx_server_stopped", {"port": port, "pid": pid}, redacted=False)
|
|
628
|
+
return {"stopped": True, "pid": pid, "port": port}
|
|
629
|
+
|
|
630
|
+
# ------------------------------------------------------------------
|
|
631
|
+
# finetune — on-fleet LoRA (the recursive flywheel's training step)
|
|
632
|
+
# ------------------------------------------------------------------
|
|
633
|
+
|
|
634
|
+
@capability(
|
|
635
|
+
id="chp.adapters.mlx.finetune",
|
|
636
|
+
version="1.0.0",
|
|
637
|
+
description=(
|
|
638
|
+
"LoRA fine-tune a model on-fleet via mlx_lm.lora, detached (logs to "
|
|
639
|
+
"~/.chp/logs/mlx-finetune-<name>.log). Provide the dataset inline via "
|
|
640
|
+
"*train*/*valid* (JSONL string or list of records) — the node writes its "
|
|
641
|
+
"own data dir, so data and compute stay co-located — or point *data* at a "
|
|
642
|
+
"pre-staged dir with train.jsonl / valid.jsonl. By default frees the GPU "
|
|
643
|
+
"first (stops any served model; a served model + training OOM a single-GPU "
|
|
644
|
+
"node). Produces a LoRA at adapter_path that mlx.start_server serves via "
|
|
645
|
+
"--adapter-path. The training step of the evidence→tune→serve flywheel."
|
|
646
|
+
),
|
|
647
|
+
category="ai",
|
|
648
|
+
provider="mlx",
|
|
649
|
+
risk="high",
|
|
650
|
+
side_effects=["process_spawn", "model_training", "process_kill"],
|
|
651
|
+
emits=_EMITS,
|
|
652
|
+
input_schema={
|
|
653
|
+
"type": "object",
|
|
654
|
+
"properties": {
|
|
655
|
+
"model": {"type": "string", "description": "Base model to fine-tune (defaults to MLX_MODEL)."},
|
|
656
|
+
"train": {"type": ["string", "array"], "description": "Inline training data: JSONL string or list of records (mlx_lm chat/completions format). Materialized on the node."},
|
|
657
|
+
"valid": {"type": ["string", "array"], "description": "Inline validation data (same shape as train)."},
|
|
658
|
+
"data": {"type": "string", "description": "Alternative to train/valid: a pre-staged dir with train.jsonl / valid.jsonl."},
|
|
659
|
+
"adapter_path": {"type": "string", "description": "Output directory for the LoRA adapter."},
|
|
660
|
+
"iters": {"type": "integer", "minimum": 1, "maximum": 100000, "default": 300},
|
|
661
|
+
"batch_size": {"type": "integer", "minimum": 1, "maximum": 64, "default": 4},
|
|
662
|
+
"num_layers": {"type": "integer", "minimum": 1, "description": "LoRA layers (default: mlx_lm default)."},
|
|
663
|
+
"free_gpu": {"type": "boolean", "default": True, "description": "Stop any served mlx model before training to avoid GPU OOM."},
|
|
664
|
+
},
|
|
665
|
+
"required": ["adapter_path"],
|
|
666
|
+
"additionalProperties": False,
|
|
667
|
+
},
|
|
668
|
+
)
|
|
669
|
+
async def finetune(self, ctx: Any, payload: dict) -> dict:
|
|
670
|
+
model = payload.get("model") or self._config.resolved_default_model()
|
|
671
|
+
if not model:
|
|
672
|
+
raise ValueError("No model specified and no MLX_MODEL configured.")
|
|
673
|
+
adapter_path = str(payload["adapter_path"])
|
|
674
|
+
iters = int(payload.get("iters") or 300)
|
|
675
|
+
batch_size = int(payload.get("batch_size") or 4)
|
|
676
|
+
num_layers = payload.get("num_layers")
|
|
677
|
+
name = os.path.basename(adapter_path.rstrip("/")) or "lora"
|
|
678
|
+
|
|
679
|
+
# Dataset: inline train/valid (materialized on this node) or a pre-staged dir.
|
|
680
|
+
train = payload.get("train")
|
|
681
|
+
valid = payload.get("valid")
|
|
682
|
+
if train is not None or valid is not None:
|
|
683
|
+
data = _materialize_dataset(name, train, valid)
|
|
684
|
+
elif payload.get("data"):
|
|
685
|
+
data = str(payload["data"])
|
|
686
|
+
else:
|
|
687
|
+
raise ValueError("finetune needs inline `train`/`valid` content or a `data` directory.")
|
|
688
|
+
|
|
689
|
+
# Free the GPU: a served model and LoRA training cannot share a single-GPU node.
|
|
690
|
+
freed = _stop_all_mlx_servers() if payload.get("free_gpu", True) else []
|
|
691
|
+
|
|
692
|
+
env = _service_safe_env()
|
|
693
|
+
log_dir = os.path.join(env["HOME"], ".chp", "logs")
|
|
694
|
+
os.makedirs(log_dir, exist_ok=True)
|
|
695
|
+
os.makedirs(adapter_path, exist_ok=True)
|
|
696
|
+
pidfile = os.path.join(_run_dir(), f"mlx-finetune-{name}.pid")
|
|
697
|
+
fd = os.open(os.path.join(log_dir, f"mlx-finetune-{name}.log"),
|
|
698
|
+
os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o644)
|
|
699
|
+
try:
|
|
700
|
+
proc = subprocess.Popen(
|
|
701
|
+
_lora_cmd(model, data, adapter_path, iters, batch_size, num_layers),
|
|
702
|
+
stdout=fd, stderr=fd, start_new_session=True, env=env)
|
|
703
|
+
finally:
|
|
704
|
+
os.close(fd)
|
|
705
|
+
_write_pid(pidfile, proc.pid)
|
|
706
|
+
ctx.emit("mlx_finetune_started",
|
|
707
|
+
{"model": model, "iters": iters, "adapter_path": adapter_path,
|
|
708
|
+
"pid": proc.pid, "freed_servers": len(freed)}, redacted=False)
|
|
709
|
+
note = ("LoRA training runs detached; tail ~/.chp/logs/mlx-finetune-"
|
|
710
|
+
f"{name}.log. When done, serve with mlx.start_server adapter_path=" + adapter_path)
|
|
711
|
+
if freed:
|
|
712
|
+
note += f" (freed {len(freed)} served model(s) for GPU headroom — re-serve after)"
|
|
713
|
+
return {
|
|
714
|
+
"started": True,
|
|
715
|
+
"pid": proc.pid,
|
|
716
|
+
"model": model,
|
|
717
|
+
"adapter_path": adapter_path,
|
|
718
|
+
"data": data,
|
|
719
|
+
"iters": iters,
|
|
720
|
+
"freed_servers": freed,
|
|
721
|
+
"note": note,
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
# ------------------------------------------------------------------
|
|
725
|
+
# eval — the flywheel's promotion gate (score the currently-served model)
|
|
726
|
+
# ------------------------------------------------------------------
|
|
727
|
+
|
|
728
|
+
@capability(
|
|
729
|
+
id="chp.adapters.mlx.eval",
|
|
730
|
+
version="1.0.0",
|
|
731
|
+
description=(
|
|
732
|
+
"Evaluate the currently-served model against a held-out eval set: generate a "
|
|
733
|
+
"completion per example and score it against a reference (deterministic "
|
|
734
|
+
"token-F1 or substring). Returns the aggregate mean + per-example scores. "
|
|
735
|
+
"Scores only in evidence — prompt/reference/completion text is never recorded. "
|
|
736
|
+
"The flywheel's promotion gate: eval base, swap in the tuned adapter, eval "
|
|
737
|
+
"again, and promote only if the tuned mean wins by a margin."
|
|
738
|
+
),
|
|
739
|
+
category="ai",
|
|
740
|
+
provider="mlx",
|
|
741
|
+
risk="medium",
|
|
742
|
+
side_effects=["llm_inference"],
|
|
743
|
+
emits=_EMITS,
|
|
744
|
+
input_schema={
|
|
745
|
+
"type": "object",
|
|
746
|
+
"properties": {
|
|
747
|
+
"model": {"type": "string", "description": "Defaults to the served default model."},
|
|
748
|
+
"eval_set": {
|
|
749
|
+
"type": "array", "minItems": 1,
|
|
750
|
+
"description": "Held-out examples; each has `prompt` (or `messages`) and a `reference` answer.",
|
|
751
|
+
"items": {
|
|
752
|
+
"type": "object",
|
|
753
|
+
"properties": {
|
|
754
|
+
"prompt": {"type": "string"},
|
|
755
|
+
"messages": {"type": "array", "items": {"type": "object", "additionalProperties": True}},
|
|
756
|
+
"reference": {"type": "string"},
|
|
757
|
+
},
|
|
758
|
+
"additionalProperties": True,
|
|
759
|
+
},
|
|
760
|
+
},
|
|
761
|
+
"metric": {"type": "string", "enum": ["f1", "contains"], "default": "f1"},
|
|
762
|
+
"max_tokens": {"type": "integer", "minimum": 1, "maximum": 4096, "default": 256},
|
|
763
|
+
"temperature": {"type": "number", "minimum": 0.0, "maximum": 2.0, "default": 0.0},
|
|
764
|
+
},
|
|
765
|
+
"required": ["eval_set"],
|
|
766
|
+
"additionalProperties": False,
|
|
767
|
+
},
|
|
768
|
+
)
|
|
769
|
+
async def evaluate(self, ctx: Any, payload: dict) -> dict:
|
|
770
|
+
model = self._model(payload)
|
|
771
|
+
items: list[dict] = payload["eval_set"]
|
|
772
|
+
metric = payload.get("metric", "f1")
|
|
773
|
+
max_tokens = int(payload.get("max_tokens") or 256)
|
|
774
|
+
temperature = float(payload.get("temperature", 0.0))
|
|
775
|
+
|
|
776
|
+
ctx.emit("mlx_eval_started", {"model": model, "n": len(items), "metric": metric}, redacted=False)
|
|
777
|
+
t0 = time.monotonic()
|
|
778
|
+
scores: list[float] = []
|
|
779
|
+
predictions: list[str] = []
|
|
780
|
+
references: list[str] = []
|
|
781
|
+
try:
|
|
782
|
+
for ex in items:
|
|
783
|
+
msgs = ex.get("messages") or [{"role": "user", "content": ex.get("prompt", "")}]
|
|
784
|
+
body = {"model": model, "messages": msgs, "max_tokens": max_tokens, "temperature": temperature}
|
|
785
|
+
data = await self._http(ctx, "POST", "/v1/chat/completions", body)
|
|
786
|
+
resp = data.get("json") or {}
|
|
787
|
+
choice = (resp.get("choices") or [{}])[0]
|
|
788
|
+
completion = (choice.get("message") or {}).get("content") or ""
|
|
789
|
+
ref = ex.get("reference", "")
|
|
790
|
+
predictions.append(completion)
|
|
791
|
+
references.append(ref)
|
|
792
|
+
scores.append(_score_one(completion, ref, metric))
|
|
793
|
+
except Exception as exc:
|
|
794
|
+
ctx.emit("mlx_eval_failed", {"model": model, "error": str(exc)[:300]}, redacted=False)
|
|
795
|
+
raise
|
|
796
|
+
|
|
797
|
+
mean = round(sum(scores) / len(scores), 4) if scores else 0.0
|
|
798
|
+
latency_ms = round((time.monotonic() - t0) * 1000)
|
|
799
|
+
ctx.emit("mlx_eval_completed",
|
|
800
|
+
{"model": model, "n": len(scores), "mean_score": mean, "metric": metric,
|
|
801
|
+
"latency_ms": latency_ms}, redacted=False)
|
|
802
|
+
# predictions/references are returned (NOT evidenced) so a caller can re-score with any
|
|
803
|
+
# metric — e.g. flywheel.evaluate_and_gate scoring via chp.adapters.huggingface.evaluate.
|
|
804
|
+
return {"model": model, "n": len(scores), "metric": metric,
|
|
805
|
+
"mean_score": mean, "scores": scores, "latency_ms": latency_ms,
|
|
806
|
+
"predictions": predictions, "references": references}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.25"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "chp-adapter-mlx"
|
|
7
|
+
version = "0.24.0"
|
|
8
|
+
description = "CHP capability adapter — Apple Silicon native text generation via a local mlx_lm server"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "Apache-2.0" }
|
|
12
|
+
authors = [{ name = "Auxo" }]
|
|
13
|
+
keywords = ["chp", "capability-host-protocol", "mlx", "apple-silicon", "generation", "openai", "adapter"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: Apache Software License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.10",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Programming Language :: Python :: 3.13",
|
|
23
|
+
]
|
|
24
|
+
dependencies = [
|
|
25
|
+
"chp-core>=0.7.0",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[project.entry-points."chp.adapters"]
|
|
29
|
+
mlx = "chp_adapter_mlx:MLXAdapter"
|
|
30
|
+
|
|
31
|
+
[project.optional-dependencies]
|
|
32
|
+
dev = ["pytest>=8.0", "pytest-asyncio>=0.23"]
|
|
33
|
+
# Runtime needed to actually SERVE a model (mlx.start_server). Heavy + Apple-Silicon
|
|
34
|
+
# only, so it's an extra: install with `chp-adapter-mlx[serve]` (the install_adapter
|
|
35
|
+
# `extras` param does this when pushing the adapter to a node).
|
|
36
|
+
serve = ["mlx-lm>=0.20"]
|
|
37
|
+
|
|
38
|
+
[tool.pytest.ini_options]
|
|
39
|
+
testpaths = ["tests"]
|
|
40
|
+
pythonpath = ["."]
|
|
41
|
+
asyncio_mode = "strict"
|
|
42
|
+
|
|
43
|
+
[tool.hatch.build.targets.wheel]
|
|
44
|
+
packages = ["chp_adapter_mlx"]
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
"""Tests for chp-adapter-mlx.
|
|
2
|
+
|
|
3
|
+
A fake ``chp.adapters.http`` capability is registered on the host, so these tests
|
|
4
|
+
exercise the real lego-block composition path (MLXAdapter → ctx.ainvoke →
|
|
5
|
+
http.request) with no mlx_lm server and no HTTP library.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import os
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from chp_adapter_mlx import MLXAdapter, MLXConfig
|
|
15
|
+
from chp_core import BaseAdapter, LocalCapabilityHost, capability, register_adapter
|
|
16
|
+
from chp_core.store import SQLiteEvidenceStore
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# ---------------------------------------------------------------------------
|
|
20
|
+
# Fake http adapter — canned mlx_lm OpenAI responses (optionally "server down")
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
class FakeHttpAdapter(BaseAdapter):
|
|
24
|
+
adapter_id = "chp.adapters.http"
|
|
25
|
+
adapter_name = "FakeHttp"
|
|
26
|
+
adapter_description = "Canned mlx_lm OpenAI responses for composition tests."
|
|
27
|
+
adapter_category = "execution"
|
|
28
|
+
|
|
29
|
+
def __init__(self, server_up: bool = True) -> None:
|
|
30
|
+
self._server_up = server_up
|
|
31
|
+
|
|
32
|
+
@capability(
|
|
33
|
+
id="chp.adapters.http.request",
|
|
34
|
+
version="1.0.0",
|
|
35
|
+
description="Fake HTTP request returning canned mlx_lm payloads by URL path.",
|
|
36
|
+
category="execution",
|
|
37
|
+
risk="low",
|
|
38
|
+
emits=["http_request", "http_response"],
|
|
39
|
+
input_schema={
|
|
40
|
+
"type": "object",
|
|
41
|
+
"properties": {
|
|
42
|
+
"method": {"type": "string"},
|
|
43
|
+
"url": {"type": "string"},
|
|
44
|
+
"headers": {"type": "object", "additionalProperties": {"type": "string"}},
|
|
45
|
+
"json_body": {},
|
|
46
|
+
"timeout": {"type": "number"},
|
|
47
|
+
},
|
|
48
|
+
"required": ["method", "url"],
|
|
49
|
+
"additionalProperties": False,
|
|
50
|
+
},
|
|
51
|
+
)
|
|
52
|
+
async def request(self, ctx: Any, payload: dict) -> dict:
|
|
53
|
+
url = payload["url"]
|
|
54
|
+
ctx.emit("http_request", {"method": payload["method"], "url": url}, redacted=False)
|
|
55
|
+
|
|
56
|
+
if not self._server_up:
|
|
57
|
+
return {"status_code": 503, "json": None, "body": "", "headers": {}}
|
|
58
|
+
|
|
59
|
+
if url.endswith("/v1/completions"):
|
|
60
|
+
body = {
|
|
61
|
+
"choices": [{"text": "GENERATED_COMPLETION_TEXT", "finish_reason": "stop"}],
|
|
62
|
+
"usage": {"prompt_tokens": 7, "completion_tokens": 11, "total_tokens": 18},
|
|
63
|
+
}
|
|
64
|
+
elif url.endswith("/v1/chat/completions"):
|
|
65
|
+
body = {
|
|
66
|
+
"choices": [{"message": {"role": "assistant", "content": "CHAT_REPLY_TEXT"}, "finish_reason": "stop"}],
|
|
67
|
+
"usage": {"prompt_tokens": 9, "completion_tokens": 5, "total_tokens": 14},
|
|
68
|
+
}
|
|
69
|
+
elif url.endswith("/v1/models"):
|
|
70
|
+
body = {"data": [{"id": "mlx-community/Qwen3-4B-4bit", "owned_by": "mlx"}]}
|
|
71
|
+
else:
|
|
72
|
+
return {"status_code": 404, "json": None, "body": "", "headers": {}}
|
|
73
|
+
|
|
74
|
+
ctx.emit("http_response", {"url": url, "status_code": 200}, redacted=False)
|
|
75
|
+
return {
|
|
76
|
+
"status_code": 200,
|
|
77
|
+
"headers": {"content-type": "application/json"},
|
|
78
|
+
"body": "",
|
|
79
|
+
"json": body,
|
|
80
|
+
"content_type": "application/json",
|
|
81
|
+
"url": url,
|
|
82
|
+
"duration_ms": 1,
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _make_host(default_model: str = "test-model", server_up: bool = True) -> LocalCapabilityHost:
|
|
87
|
+
store = SQLiteEvidenceStore(":memory:")
|
|
88
|
+
host = LocalCapabilityHost(store=store)
|
|
89
|
+
register_adapter(host, FakeHttpAdapter(server_up=server_up))
|
|
90
|
+
register_adapter(host, MLXAdapter(MLXConfig(default_model=default_model)))
|
|
91
|
+
return host
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _invoke(host: LocalCapabilityHost, cap_id: str, payload: dict | None = None):
|
|
95
|
+
return asyncio.get_event_loop().run_until_complete(host.ainvoke(cap_id, payload or {}))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# ---------------------------------------------------------------------------
|
|
99
|
+
# MLXConfig
|
|
100
|
+
# ---------------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
class TestMLXConfig:
|
|
103
|
+
def test_default_base_url(self, monkeypatch):
|
|
104
|
+
monkeypatch.delenv("MLX_BASE_URL", raising=False)
|
|
105
|
+
assert MLXConfig().resolved_base_url() == "http://localhost:8081"
|
|
106
|
+
|
|
107
|
+
def test_base_url_from_env(self, monkeypatch):
|
|
108
|
+
monkeypatch.setenv("MLX_BASE_URL", "http://mlx:9000")
|
|
109
|
+
assert MLXConfig().resolved_base_url() == "http://mlx:9000"
|
|
110
|
+
|
|
111
|
+
def test_model_from_env(self, monkeypatch):
|
|
112
|
+
monkeypatch.setenv("MLX_MODEL", "mlx-community/model")
|
|
113
|
+
assert MLXConfig().resolved_default_model() == "mlx-community/model"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# ---------------------------------------------------------------------------
|
|
117
|
+
# generate / chat / list_models
|
|
118
|
+
# ---------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
class TestGenerate:
|
|
121
|
+
def test_returns_text_and_tokens(self):
|
|
122
|
+
result = _invoke(_make_host(), "chp.adapters.mlx.generate", {"prompt": "Hello"})
|
|
123
|
+
assert result.success
|
|
124
|
+
assert result.data["text"] == "GENERATED_COMPLETION_TEXT"
|
|
125
|
+
assert result.data["prompt_tokens"] == 7
|
|
126
|
+
assert result.data["completion_tokens"] == 11
|
|
127
|
+
assert result.data["finish_reason"] == "stop"
|
|
128
|
+
|
|
129
|
+
def test_prompt_and_completion_not_in_evidence(self):
|
|
130
|
+
host = _make_host()
|
|
131
|
+
result = _invoke(host, "chp.adapters.mlx.generate", {"prompt": "SECRET_PROMPT_XYZ"})
|
|
132
|
+
assert result.success
|
|
133
|
+
for evt in host.replay(result.invocation_id):
|
|
134
|
+
blob = str(evt.get("payload", {}))
|
|
135
|
+
assert "SECRET_PROMPT_XYZ" not in blob
|
|
136
|
+
assert "GENERATED_COMPLETION_TEXT" not in blob
|
|
137
|
+
|
|
138
|
+
def test_missing_model_raises(self):
|
|
139
|
+
result = _invoke(_make_host(default_model=""), "chp.adapters.mlx.generate", {"prompt": "Hello"})
|
|
140
|
+
assert not result.success
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class TestChat:
|
|
144
|
+
def test_returns_message_and_tokens(self):
|
|
145
|
+
result = _invoke(_make_host(), "chp.adapters.mlx.chat", {
|
|
146
|
+
"messages": [{"role": "user", "content": "hi"}],
|
|
147
|
+
})
|
|
148
|
+
assert result.success
|
|
149
|
+
assert result.data["message"]["content"] == "CHAT_REPLY_TEXT"
|
|
150
|
+
assert result.data["prompt_tokens"] == 9
|
|
151
|
+
|
|
152
|
+
def test_message_content_not_in_evidence(self):
|
|
153
|
+
host = _make_host()
|
|
154
|
+
result = _invoke(host, "chp.adapters.mlx.chat", {
|
|
155
|
+
"messages": [{"role": "user", "content": "SECRET_MESSAGE_ABC"}],
|
|
156
|
+
})
|
|
157
|
+
assert result.success
|
|
158
|
+
for evt in host.replay(result.invocation_id):
|
|
159
|
+
assert "SECRET_MESSAGE_ABC" not in str(evt.get("payload", {}))
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class TestListModels:
|
|
163
|
+
def test_returns_models(self):
|
|
164
|
+
result = _invoke(_make_host(), "chp.adapters.mlx.list_models", {})
|
|
165
|
+
assert result.success
|
|
166
|
+
assert result.data["model_count"] == 1
|
|
167
|
+
assert result.data["models"][0]["id"] == "mlx-community/Qwen3-4B-4bit"
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# ---------------------------------------------------------------------------
|
|
171
|
+
# status — the "is MLX on this machine and serving?" check
|
|
172
|
+
# ---------------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
class TestStatus:
|
|
175
|
+
def test_reports_package_availability_and_healthy_server(self):
|
|
176
|
+
result = _invoke(_make_host(), "chp.adapters.mlx.status", {})
|
|
177
|
+
assert result.success
|
|
178
|
+
d = result.data
|
|
179
|
+
# Package availability is reported as booleans (value depends on the host env).
|
|
180
|
+
assert isinstance(d["mlx_installed"], bool)
|
|
181
|
+
assert isinstance(d["mlx_lm_installed"], bool)
|
|
182
|
+
# The (fake) server is reachable.
|
|
183
|
+
assert d["server_healthy"] is True
|
|
184
|
+
assert d["model_count"] == 1
|
|
185
|
+
assert d["server_url"] == "http://localhost:8081"
|
|
186
|
+
|
|
187
|
+
def test_status_succeeds_even_when_server_down(self):
|
|
188
|
+
result = _invoke(_make_host(server_up=False), "chp.adapters.mlx.status", {})
|
|
189
|
+
assert result.success # status never fails on an unreachable server
|
|
190
|
+
assert result.data["server_healthy"] is False
|
|
191
|
+
assert result.data["server_error"]
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
# ---------------------------------------------------------------------------
|
|
195
|
+
# start_server / stop_server — process lifecycle (mocked subprocess)
|
|
196
|
+
# ---------------------------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
class TestServerLifecycle:
|
|
199
|
+
def test_start_server_spawns_detached(self, monkeypatch, tmp_path):
|
|
200
|
+
import chp_adapter_mlx.adapter as mod
|
|
201
|
+
calls = {}
|
|
202
|
+
|
|
203
|
+
class FakeProc:
|
|
204
|
+
pid = 555
|
|
205
|
+
|
|
206
|
+
monkeypatch.setattr(mod, "_run_dir", lambda: str(tmp_path))
|
|
207
|
+
monkeypatch.setattr(mod.subprocess, "Popen",
|
|
208
|
+
lambda cmd, **kw: calls.update(cmd=cmd, kw=kw) or FakeProc())
|
|
209
|
+
result = _invoke(_make_host(), "chp.adapters.mlx.start_server",
|
|
210
|
+
{"model": "mlx-community/Qwen3-4B-4bit", "port": 8081})
|
|
211
|
+
assert result.success
|
|
212
|
+
assert result.data["started"] is True
|
|
213
|
+
assert result.data["pid"] == 555
|
|
214
|
+
assert "--model" in calls["cmd"] and "mlx-community/Qwen3-4B-4bit" in calls["cmd"]
|
|
215
|
+
assert "--port" in calls["cmd"] and "8081" in calls["cmd"]
|
|
216
|
+
assert calls["kw"].get("start_new_session") is True
|
|
217
|
+
# pidfile written
|
|
218
|
+
assert (tmp_path / "mlx-server-8081.pid").exists()
|
|
219
|
+
|
|
220
|
+
def test_start_server_idempotent_when_running(self, monkeypatch, tmp_path):
|
|
221
|
+
import chp_adapter_mlx.adapter as mod
|
|
222
|
+
monkeypatch.setattr(mod, "_run_dir", lambda: str(tmp_path))
|
|
223
|
+
(tmp_path / "mlx-server-8081.pid").write_text(str(os.getpid())) # a live pid (this test proc)
|
|
224
|
+
called = {"popen": False}
|
|
225
|
+
monkeypatch.setattr(mod.subprocess, "Popen",
|
|
226
|
+
lambda *a, **k: called.update(popen=True))
|
|
227
|
+
result = _invoke(_make_host(), "chp.adapters.mlx.start_server", {"model": "m", "port": 8081})
|
|
228
|
+
assert result.success
|
|
229
|
+
assert result.data["already_running"] is True
|
|
230
|
+
assert called["popen"] is False # did not spawn a second server
|
|
231
|
+
|
|
232
|
+
def test_start_server_requires_model(self):
|
|
233
|
+
result = _invoke(_make_host(default_model=""), "chp.adapters.mlx.start_server", {})
|
|
234
|
+
assert not result.success
|
|
235
|
+
|
|
236
|
+
def test_start_server_remembers_model_as_default(self, monkeypatch, tmp_path):
|
|
237
|
+
import chp_adapter_mlx.adapter as mod
|
|
238
|
+
|
|
239
|
+
class FakeProc:
|
|
240
|
+
pid = 7
|
|
241
|
+
monkeypatch.setattr(mod, "_run_dir", lambda: str(tmp_path))
|
|
242
|
+
monkeypatch.setattr(mod.subprocess, "Popen", lambda cmd, **kw: FakeProc())
|
|
243
|
+
# Adapter with no default model; chat would fail before start_server.
|
|
244
|
+
adapter = MLXAdapter(MLXConfig(default_model=""))
|
|
245
|
+
host = LocalCapabilityHost(store=SQLiteEvidenceStore(":memory:"))
|
|
246
|
+
register_adapter(host, FakeHttpAdapter())
|
|
247
|
+
register_adapter(host, adapter)
|
|
248
|
+
_invoke(host, "chp.adapters.mlx.start_server",
|
|
249
|
+
{"model": "mlx-community/Qwen3-4B-4bit", "port": 8099})
|
|
250
|
+
# Now chat needs no explicit model — the started model is the default.
|
|
251
|
+
assert adapter._config.resolved_default_model() == "mlx-community/Qwen3-4B-4bit"
|
|
252
|
+
r = _invoke(host, "chp.adapters.mlx.chat", {"messages": [{"role": "user", "content": "hi"}]})
|
|
253
|
+
assert r.success
|
|
254
|
+
|
|
255
|
+
def test_finetune_spawns_lora_detached(self, monkeypatch, tmp_path):
|
|
256
|
+
import chp_adapter_mlx.adapter as mod
|
|
257
|
+
|
|
258
|
+
class FakeProc:
|
|
259
|
+
pid = 888
|
|
260
|
+
calls = {}
|
|
261
|
+
monkeypatch.setattr(mod, "_run_dir", lambda: str(tmp_path))
|
|
262
|
+
monkeypatch.setattr(mod.subprocess, "Popen",
|
|
263
|
+
lambda cmd, **kw: calls.update(cmd=cmd, kw=kw) or FakeProc())
|
|
264
|
+
result = _invoke(_make_host(), "chp.adapters.mlx.finetune",
|
|
265
|
+
{"model": "m", "data": str(tmp_path), "adapter_path": str(tmp_path / "lora"),
|
|
266
|
+
"iters": 50, "batch_size": 2})
|
|
267
|
+
assert result.success
|
|
268
|
+
assert result.data["started"] is True and result.data["pid"] == 888
|
|
269
|
+
cmd = calls["cmd"]
|
|
270
|
+
assert "--train" in cmd and "--data" in cmd and "--adapter-path" in cmd
|
|
271
|
+
assert "--iters" in cmd and "50" in cmd
|
|
272
|
+
assert calls["kw"].get("start_new_session") is True
|
|
273
|
+
|
|
274
|
+
def test_start_server_adapter_path_serves_lora(self, monkeypatch, tmp_path):
|
|
275
|
+
import chp_adapter_mlx.adapter as mod
|
|
276
|
+
calls = {}
|
|
277
|
+
monkeypatch.setattr(mod, "_run_dir", lambda: str(tmp_path))
|
|
278
|
+
monkeypatch.setattr(mod.subprocess, "Popen",
|
|
279
|
+
lambda cmd, **kw: calls.update(cmd=cmd) or type("P", (), {"pid": 5})())
|
|
280
|
+
_invoke(_make_host(), "chp.adapters.mlx.start_server",
|
|
281
|
+
{"model": "m", "port": 8087, "adapter_path": "/x/lora"})
|
|
282
|
+
assert "--adapter-path" in calls["cmd"] and "/x/lora" in calls["cmd"]
|
|
283
|
+
|
|
284
|
+
def test_stop_server_when_not_running(self, monkeypatch, tmp_path):
|
|
285
|
+
import chp_adapter_mlx.adapter as mod
|
|
286
|
+
monkeypatch.setattr(mod, "_run_dir", lambda: str(tmp_path))
|
|
287
|
+
result = _invoke(_make_host(), "chp.adapters.mlx.stop_server", {"port": 8081})
|
|
288
|
+
assert result.success
|
|
289
|
+
assert result.data["stopped"] is False
|
|
290
|
+
assert result.data["running"] is False
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
# ---------------------------------------------------------------------------
|
|
294
|
+
# Conformance — MLX adapter imports no HTTP library (composes via router)
|
|
295
|
+
# ---------------------------------------------------------------------------
|
|
296
|
+
|
|
297
|
+
class TestConformance:
|
|
298
|
+
def test_adapter_has_no_violations(self):
|
|
299
|
+
import inspect
|
|
300
|
+
|
|
301
|
+
from chp_adapter_conformance import check_source_file
|
|
302
|
+
import chp_adapter_mlx.adapter as mod
|
|
303
|
+
|
|
304
|
+
violations = check_source_file(inspect.getfile(mod))
|
|
305
|
+
assert not violations, f"MLXAdapter has conformance violations: {violations}"
|