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.
@@ -0,0 +1,16 @@
1
+ node_modules/
2
+ dist/
3
+ *.egg-info/
4
+ __pycache__/
5
+ .pytest_cache/
6
+ .chp/
7
+ .DS_Store
8
+ *.tgz
9
+ .env
10
+ .env.*
11
+ *.pyc
12
+ .python-version
13
+ .coverage
14
+ .hypothesis/
15
+ .chp-agent/
16
+ *.sqlite
@@ -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}"