@gobing-ai/ts-laya-mlx 0.5.1

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,208 @@
1
+ #!/usr/bin/env python3
2
+ """Long-lived JSON-lines worker around the installed ``laya_mlx`` runtime (ADR-027).
3
+
4
+ Lifecycle: resolve options (CLI args, else the first stdin line) -> import the runtime
5
+ module -> construct one Agent -> emit the handshake -> serve one JSON object per line
6
+ until EOF. The worker owns lifecycle and framing only: tokenization, batching,
7
+ calibration, and answer construction are the runtime's (``Agent.predict``). A request
8
+ carrying more questions than the configured batch size is chunked inside the runtime;
9
+ every answer is returned keyed by its question name.
10
+
11
+ Line protocol (docs/design/laya-local-decision-backend.md):
12
+
13
+ handshake {"ready": true, "model": "...", "revision": "...|null", "maxLen": N}
14
+ request {"id": "...", "state": "...", "questions": {"<qid>": {...}}}
15
+ success {"id": "...", "ok": true, "result": <runtime predict payload>}
16
+ failure {"id": "...", "ok": false, "error": {"kind": "config|request|backend", "message": "..."}}
17
+ startup {"ready": false, "error": {"kind": "config", "message": "..."}} then exit 1
18
+
19
+ Error kinds: ``config`` — construction/host problems (model resolution, dtype, batch
20
+ size); ``request`` — caller problems (malformed line, malformed question); ``backend``
21
+ — model problems (non-finite outputs, unexpected runtime failure).
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import argparse
27
+ import importlib
28
+ import json
29
+ import os
30
+ import sys
31
+
32
+ DEFAULT_MODULE = "laya_mlx"
33
+
34
+
35
+ def parse_args(argv):
36
+ parser = argparse.ArgumentParser(
37
+ prog="laya_worker.py",
38
+ description="JSON-lines worker executing one laya_mlx.Agent for its lifetime.",
39
+ )
40
+ parser.add_argument("--model", default=None, help="Hugging Face model id")
41
+ parser.add_argument("--model-path", default=None, help="local checkpoint directory (wins over --model)")
42
+ parser.add_argument("--revision", default=None, help="checkpoint revision")
43
+ # dtype/batch-size are validated by the runtime's Agent constructor so a bad
44
+ # value surfaces as the structured config handshake, not an argparse exit.
45
+ parser.add_argument("--dtype", default="float16", help="weight precision")
46
+ parser.add_argument("--batch-size", type=int, default=16, help="questions per forward pass")
47
+ parser.add_argument("--cache-dir", default=None, help="artifact cache root (exported as HF_HUB_CACHE)")
48
+ parser.add_argument(
49
+ "--module",
50
+ default=DEFAULT_MODULE,
51
+ help="runtime module to import (default: laya_mlx); a non-default module is "
52
+ "resolved from the working directory so tests can inject a stub",
53
+ )
54
+ return parser.parse_args(argv)
55
+
56
+
57
+ def merge_first_line_options(args, line):
58
+ """Apply construction options from a first line of ``{"options": {...}}``.
59
+
60
+ Returns ``(args, applied)``. ``applied`` is False when the line is absent,
61
+ unparsable, or an ordinary request — in every case the caller must still
62
+ serve the line so no request is ever dropped.
63
+ """
64
+ if line is None or not line.strip():
65
+ return args, False
66
+ try:
67
+ parsed = json.loads(line)
68
+ except json.JSONDecodeError:
69
+ return args, False
70
+ if not isinstance(parsed, dict) or not isinstance(parsed.get("options"), dict):
71
+ return args, False
72
+ supplied = parsed["options"]
73
+ if "model" in supplied:
74
+ args.model = supplied["model"]
75
+ if "modelPath" in supplied:
76
+ args.model_path = supplied["modelPath"]
77
+ if "revision" in supplied:
78
+ args.revision = supplied["revision"]
79
+ if "dtype" in supplied:
80
+ args.dtype = supplied["dtype"]
81
+ if "batchSize" in supplied:
82
+ args.batch_size = supplied["batchSize"]
83
+ if "cacheDir" in supplied:
84
+ args.cache_dir = supplied["cacheDir"]
85
+ return args, True
86
+
87
+
88
+ def emit(payload):
89
+ sys.stdout.write(json.dumps(payload, ensure_ascii=False) + "\n")
90
+ sys.stdout.flush()
91
+
92
+
93
+ def startup_failure(message):
94
+ emit({"ready": False, "error": {"kind": "config", "message": message}})
95
+
96
+
97
+ def build_agent(args):
98
+ module_name = args.module
99
+ if module_name != DEFAULT_MODULE:
100
+ # Test seam: resolve an injected module from the working directory.
101
+ sys.path.insert(0, os.getcwd())
102
+
103
+ # huggingface_hub reads cache locations at import time, so the cache root must
104
+ # be exported before the import below; it is config plumbing, not model logic.
105
+ if args.cache_dir:
106
+ os.environ["HF_HUB_CACHE"] = args.cache_dir
107
+
108
+ module = importlib.import_module(module_name)
109
+ agent_cls = getattr(module, "Agent")
110
+
111
+ model = args.model_path or args.model
112
+ kwargs = {}
113
+ if model is not None:
114
+ kwargs["model_id_or_path"] = model
115
+ if args.revision is not None:
116
+ kwargs["revision"] = args.revision
117
+ kwargs["dtype"] = args.dtype
118
+ kwargs["batch_size"] = args.batch_size
119
+ kwargs["token"] = os.environ.get("HF_TOKEN")
120
+ return agent_cls(**kwargs)
121
+
122
+
123
+ def request_error(request_id, message):
124
+ return {"id": request_id, "ok": False, "error": {"kind": "request", "message": message}}
125
+
126
+
127
+ def classify(exc):
128
+ """Map a runtime exception onto the protocol's error taxonomy."""
129
+ if isinstance(exc, FloatingPointError):
130
+ return "backend"
131
+ if isinstance(exc, ValueError):
132
+ return "request"
133
+ return "backend"
134
+
135
+
136
+ def handle_request(agent, line):
137
+ try:
138
+ request = json.loads(line)
139
+ except json.JSONDecodeError as exc:
140
+ return request_error("", f"malformed JSON line: {exc}")
141
+ if not isinstance(request, dict):
142
+ return request_error("", "request line must be a JSON object")
143
+ if "id" not in request:
144
+ return request_error("", "request is missing id")
145
+ request_id = request["id"]
146
+ if isinstance(request_id, bool) or not isinstance(request_id, (str, int)):
147
+ return request_error("", "request id must be a string or integer")
148
+
149
+ state = request.get("state")
150
+ if not isinstance(state, str):
151
+ return request_error(request_id, "request state must be a string")
152
+ questions = request.get("questions")
153
+ if not isinstance(questions, dict) or not questions:
154
+ return request_error(request_id, "request questions must be a nonempty object")
155
+
156
+ try:
157
+ result = agent.predict(state, questions)
158
+ except Exception as exc: # classified and reported, never fatal to the worker
159
+ return {"id": request_id, "ok": False, "error": {"kind": classify(exc), "message": str(exc)}}
160
+ return {"id": request_id, "ok": True, "result": result}
161
+
162
+
163
+ def main(argv=None):
164
+ argv = sys.argv[1:] if argv is None else list(argv)
165
+ args = parse_args(argv)
166
+
167
+ # Without CLI args the construction options may arrive on the first stdin line;
168
+ # a first line that is not an options line is the first request and must be served.
169
+ pending_line = None
170
+ if not argv:
171
+ first_line = sys.stdin.readline().rstrip("\n")
172
+ args, applied = merge_first_line_options(args, first_line)
173
+ if not applied:
174
+ pending_line = first_line
175
+
176
+ try:
177
+ agent = build_agent(args)
178
+ except Exception as exc: # every construction failure is config
179
+ startup_failure(str(exc))
180
+ return 1
181
+
182
+ emit(
183
+ {
184
+ "ready": True,
185
+ "model": str(agent.model_id),
186
+ "revision": agent.revision,
187
+ "maxLen": int(agent.cfg.get("max_len", 512)),
188
+ }
189
+ )
190
+
191
+ def input_lines():
192
+ if pending_line is not None:
193
+ yield pending_line
194
+ for line in sys.stdin:
195
+ yield line.rstrip("\n")
196
+
197
+ for line in input_lines():
198
+ if not line.strip():
199
+ continue
200
+ try:
201
+ emit(handle_request(agent, line))
202
+ except BrokenPipeError:
203
+ return 1
204
+ return 0
205
+
206
+
207
+ if __name__ == "__main__":
208
+ sys.exit(main())