lucataco commited on
Commit
0ebb1d0
·
verified ·
1 Parent(s): c4b62ef

clef_mlx.py: add 'predict' CLI and 'serve' (local POST /v1/systemone server); card: CLI and server docs

Browse files
Files changed (2) hide show
  1. README.md +38 -0
  2. clef_mlx.py +202 -0
README.md CHANGED
@@ -83,6 +83,44 @@ model.predict({
83
 
84
  See the [original model card](https://huggingface.co/Cloudflare/clef) for the input format, question types, and benchmarks.
85
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86
  ## Limitations
87
 
88
  - **Not a chat model.** Text generation tools (`mlx_lm.generate`, `mlx_vlm.generate`, LM Studio, Ollama) load
 
83
 
84
  See the [original model card](https://huggingface.co/Cloudflare/clef) for the input format, question types, and benchmarks.
85
 
86
+ ## Try it from the command line
87
+
88
+ `clef_mlx.py` runs on its own. From the downloaded repo it uses that repo by default; elsewhere pass
89
+ `--model mlx-community/clef-4bit`.
90
+
91
+ ```bash
92
+ cd "$(hf download mlx-community/clef-4bit --quiet)"
93
+ python clef_mlx.py predict \
94
+ --state "Checkout has been failing for every customer for the last hour." \
95
+ --questions '{"urgent": {"type": "noul", "instructions": "Is this urgent?"},
96
+ "team": {"type": "choice", "criteria": {"billing": "Payments", "technical": "Outages and errors"}}}'
97
+ ```
98
+
99
+ `--questions` takes JSON, a `.json` file, or `-` for stdin; `--request` takes a whole request body instead;
100
+ `--image photo.jpg` attaches an image (repeatable). The output is the SystemOne response as JSON.
101
+
102
+ ## Run a local SystemOne server
103
+
104
+ ```bash
105
+ python clef_mlx.py serve --port 8000 # http://127.0.0.1:8000, local only by default
106
+ curl http://127.0.0.1:8000/v1/systemone -H "Content-Type: application/json" -d '{
107
+ "model": "clef",
108
+ "state": "Checkout has been failing for every customer for the last hour.",
109
+ "questions": {"urgent": {"type": "noul", "instructions": "Is this urgent?"}}
110
+ }'
111
+ ```
112
+
113
+ - `POST /v1/systemone` takes the same request body as the Jev/SystemOne API and returns the same response
114
+ (plus `usage.latency_ms`), so existing SystemOne clients can point at your laptop. `GET /health` and
115
+ `GET /v1/models` are also available.
116
+ - Images go in `images` as data URLs, base64, or http(s) URLs. Videos aren't supported over HTTP.
117
+ - Errors return JSON: `400` for invalid requests, `413` with "maximum context length" for inputs that don't fit.
118
+ Add `"truncate": false` to a request (or start with `--no-truncate`) to get the `413` instead of truncation.
119
+ - Requests run one at a time on the GPU. There is no authentication: keep the default `127.0.0.1` binding
120
+ unless you put your own proxy in front.
121
+ - For the [Decision Index](https://github.com/apolinario/decision-index), start with `--no-truncate` and use
122
+ its `http` engine: `python -m decision_index run --engine http --option base_url=http://127.0.0.1:8000`.
123
+
124
  ## Limitations
125
 
126
  - **Not a chat model.** Text generation tools (`mlx_lm.generate`, `mlx_vlm.generate`, LM Studio, Ollama) load
clef_mlx.py CHANGED
@@ -8,6 +8,11 @@ Torch-free. The backbone is loaded with mlx-vlm (text + images/video) or mlx-lm
8
  model = clef_mlx.load("mlx-community/clef-flash-4bit")
9
  model.predict(record) # {question_id: {option_id: probability}}
10
  model.systemone(request) # Jev/SystemOne /v1/systemone response body
 
 
 
 
 
11
  """
12
 
13
  from __future__ import annotations
@@ -573,3 +578,200 @@ def load(path: str | Path, head_dtype=mx.bfloat16, backend: str = "auto") -> Cle
573
  head.set_dtype(head_dtype)
574
  mx.eval(head.parameters())
575
  return ClefMLX(backbone, tokenizer, head, processor)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  model = clef_mlx.load("mlx-community/clef-flash-4bit")
9
  model.predict(record) # {question_id: {option_id: probability}}
10
  model.systemone(request) # Jev/SystemOne /v1/systemone response body
11
+
12
+ Command line (from a downloaded repo, --model defaults to that repo):
13
+
14
+ python clef_mlx.py predict --state "..." --questions '{"q": {"type": "noul"}}'
15
+ python clef_mlx.py serve --port 8000 # POST /v1/systemone on 127.0.0.1
16
  """
17
 
18
  from __future__ import annotations
 
578
  head.set_dtype(head_dtype)
579
  mx.eval(head.parameters())
580
  return ClefMLX(backbone, tokenizer, head, processor)
581
+
582
+
583
+ # --------------------------------------------------------------------------- CLI and server
584
+
585
+
586
+ def _default_model() -> str | None:
587
+ """When run from inside a downloaded repo, default to that repo."""
588
+ here = Path(__file__).resolve().parent
589
+ return str(here) if (here / "joint_head.safetensors").exists() else None
590
+
591
+
592
+ def _json_arg(value: str) -> Any:
593
+ """A JSON string, a path to a JSON file, or '-' for stdin."""
594
+ if value == "-":
595
+ return json.load(__import__("sys").stdin)
596
+ try:
597
+ candidate = Path(value).expanduser()
598
+ if candidate.is_file():
599
+ return json.loads(candidate.read_text())
600
+ except OSError: # e.g. inline JSON too long to be a file name
601
+ pass
602
+ return json.loads(value)
603
+
604
+
605
+ def _state_arg(value: str) -> Any:
606
+ """State text; JSON objects/arrays are parsed so they are rendered like the reference does."""
607
+ stripped = value.strip()
608
+ if stripped[:1] in "{[":
609
+ try:
610
+ return json.loads(stripped)
611
+ except json.JSONDecodeError:
612
+ pass
613
+ return value
614
+
615
+
616
+ def _decode_image(item: Any):
617
+ """An image from a data URL, raw base64 or an http(s) URL (no local paths over HTTP)."""
618
+ import base64
619
+ import io
620
+ import urllib.request
621
+
622
+ from PIL import Image
623
+
624
+ if not isinstance(item, str) or not item:
625
+ raise ValueError("images must be strings: data URLs, base64, or http(s) URLs")
626
+ if item.startswith(("http://", "https://")):
627
+ with urllib.request.urlopen(item, timeout=30) as response:
628
+ data = response.read()
629
+ else:
630
+ if item.startswith("data:"):
631
+ item = item.split(",", 1)[-1]
632
+ try:
633
+ data = base64.b64decode(item, validate=True)
634
+ except ValueError:
635
+ raise ValueError("could not decode image: expected a data URL, base64, or http(s) URL") from None
636
+ return Image.open(io.BytesIO(data)).convert("RGB")
637
+
638
+
639
+ def _cmd_predict(args) -> None:
640
+ import sys
641
+
642
+ if args.request:
643
+ request = _json_arg(args.request)
644
+ else:
645
+ if args.state is None or args.questions is None:
646
+ sys.exit("predict needs --state and --questions, or --request")
647
+ request = {"state": _state_arg(args.state), "questions": _json_arg(args.questions)}
648
+ request.setdefault("model", args.name or Path(args.model).name)
649
+ if args.image:
650
+ from PIL import Image
651
+
652
+ request["images"] = [Image.open(Path(p).expanduser()).convert("RGB") for p in args.image]
653
+ model = load(args.model)
654
+ try:
655
+ response = model.systemone(request, max_length=args.max_length, truncate=not args.no_truncate)
656
+ except ValueError as error:
657
+ sys.exit(f"error: {error}")
658
+ print(json.dumps(response, indent=2, ensure_ascii=False))
659
+
660
+
661
+ def _cmd_serve(args) -> None:
662
+ import threading
663
+ import time
664
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
665
+
666
+ model = load(args.model)
667
+ served_name = args.name or Path(args.model).name
668
+ model.systemone({"model": served_name, "state": "warmup",
669
+ "questions": {"w": {"type": "noul", "instructions": "Is this a warmup?"}}})
670
+ lock = threading.Lock() # one GPU: run requests one at a time
671
+ max_body = args.max_body_mb * 1024 * 1024
672
+
673
+ class Handler(BaseHTTPRequestHandler):
674
+ server_version = "clef-mlx"
675
+
676
+ def _send(self, status: int, body: dict) -> None:
677
+ data = json.dumps(body, ensure_ascii=False).encode()
678
+ self.send_response(status)
679
+ self.send_header("Content-Type", "application/json")
680
+ self.send_header("Content-Length", str(len(data)))
681
+ self.end_headers()
682
+ self.wfile.write(data)
683
+
684
+ def _error(self, status: int, message: str) -> None:
685
+ self._send(status, {"error": {"message": message}})
686
+
687
+ def do_GET(self):
688
+ if self.path == "/health":
689
+ self._send(200, {"status": "ok", "model": served_name})
690
+ elif self.path == "/v1/models":
691
+ self._send(200, {"object": "list", "data": [{"id": served_name, "object": "model"}]})
692
+ else:
693
+ self._error(404, f"not found: {self.path}")
694
+
695
+ def do_POST(self):
696
+ if self.path != "/v1/systemone":
697
+ return self._error(404, f"not found: {self.path}")
698
+ length = int(self.headers.get("Content-Length") or 0)
699
+ if length > max_body:
700
+ return self._error(413, f"request body larger than {args.max_body_mb} MB")
701
+ try:
702
+ request = json.loads(self.rfile.read(length))
703
+ if not isinstance(request, dict):
704
+ raise ValueError("request body must be a JSON object")
705
+ if request.get("videos"):
706
+ raise ValueError("videos are not supported over HTTP; use clef_mlx.py from Python")
707
+ request.setdefault("model", served_name)
708
+ if request.get("images"):
709
+ request["images"] = [_decode_image(i) for i in request["images"]]
710
+ truncate = request.pop("truncate", not args.no_truncate)
711
+ except (json.JSONDecodeError, ValueError, OSError) as error:
712
+ return self._error(400, str(error))
713
+ started = time.perf_counter()
714
+ try:
715
+ with lock:
716
+ response = model.systemone(request, max_length=args.max_length, truncate=bool(truncate))
717
+ except ContextTooLong as error:
718
+ return self._error(413, f"maximum context length exceeded: {error}")
719
+ except (ValueError, KeyError, TypeError) as error:
720
+ return self._error(400, str(error))
721
+ except Exception as error: # noqa: BLE001 - report, keep serving
722
+ return self._error(500, f"{type(error).__name__}: {error}")
723
+ response["usage"]["latency_ms"] = round((time.perf_counter() - started) * 1000, 1)
724
+ self._send(200, response)
725
+
726
+ def log_message(self, fmt, *fmt_args):
727
+ if not args.quiet:
728
+ super().log_message(fmt, *fmt_args)
729
+
730
+ server = ThreadingHTTPServer((args.host, args.port), Handler)
731
+ print(f"Serving {served_name} on http://{args.host}:{args.port}/v1/systemone "
732
+ f"(truncate={'off' if args.no_truncate else 'on'}, max_length={args.max_length})", flush=True)
733
+ try:
734
+ server.serve_forever()
735
+ except KeyboardInterrupt:
736
+ pass
737
+ finally:
738
+ server.server_close()
739
+
740
+
741
+ def main(argv: list[str] | None = None) -> None:
742
+ import argparse
743
+
744
+ default_model = _default_model()
745
+ parser = argparse.ArgumentParser(prog="clef_mlx.py", description="Run Clef locally with MLX.")
746
+ sub = parser.add_subparsers(dest="command", required=True)
747
+
748
+ def common(p):
749
+ p.add_argument("--model", default=default_model, required=default_model is None,
750
+ help="Hub repo id or local dir (default: the repo this file is in)")
751
+ p.add_argument("--name", help="model name echoed in responses (default: the model dir/repo name)")
752
+ p.add_argument("--max-length", type=int, default=16384, help="prompt token limit (default 16384)")
753
+ p.add_argument("--no-truncate", action="store_true",
754
+ help="refuse over-length inputs instead of truncating the state")
755
+
756
+ p = sub.add_parser("predict", help="answer one request and print the SystemOne response")
757
+ common(p)
758
+ p.add_argument("--state", help="state text (JSON objects/arrays are parsed)")
759
+ p.add_argument("--questions", help="questions as JSON, a .json file, or '-' for stdin")
760
+ p.add_argument("--request", help="a full /v1/systemone request body as JSON, a .json file, or '-'")
761
+ p.add_argument("--image", action="append", help="image file to attach (repeatable)")
762
+ p.set_defaults(func=_cmd_predict)
763
+
764
+ p = sub.add_parser("serve", help="serve POST /v1/systemone over HTTP")
765
+ common(p)
766
+ p.add_argument("--host", default="127.0.0.1", help="bind address (default 127.0.0.1, local only)")
767
+ p.add_argument("--port", type=int, default=8000)
768
+ p.add_argument("--max-body-mb", type=int, default=64)
769
+ p.add_argument("--quiet", action="store_true", help="no per-request access log")
770
+ p.set_defaults(func=_cmd_serve)
771
+
772
+ args = parser.parse_args(argv)
773
+ args.func(args)
774
+
775
+
776
+ if __name__ == "__main__":
777
+ main()