mu-agent 0.2.4 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,344 +0,0 @@
1
- #!/usr/bin/env python3
2
- """mu local judge sidecar.
3
-
4
- Serves a Laya Core ML checkpoint (https://github.com/mizorewww/laya-coreml) over
5
- the same wire contract as the AI Gateway `evaluation-model` endpoint, so the
6
- mu kernel can swap the hosted judge model for a local one without translating anything:
7
-
8
- POST /evaluate {"state": ..., "questions": {id: {type, instructions, criteria?}}}
9
- -> {"answers": {...}, "usage": {...}, "warnings": [...], "providerMetadata": {"laya": {...}}}
10
- GET /health -> {"status": "ok" | "warming", ...}
11
-
12
- Question types use the gateway names: `boolean` (Laya calls it `noul`), `choice`, `score`.
13
-
14
- The server binds to loopback only, never logs state or question text, and keeps
15
- the model behind one lock because a Core ML model is not safe to call concurrently.
16
- """
17
-
18
- import argparse
19
- import json
20
- import os
21
- import sys
22
- import threading
23
- import time
24
- from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
25
- from pathlib import Path
26
-
27
- DEFAULT_HOST = "127.0.0.1"
28
- DEFAULT_PORT = 47823
29
- MAX_BODY_BYTES = 1024 * 1024
30
- # Every request is padded to at least this many tokens; by default (None) to the checkpoint's whole window, so that
31
- # after warm-up the input length never changes. A change of length is what crashed the sidecar: Core ML re-specializes
32
- # the GPU program from its compiled copy of the model in ~/Library/Caches/<process>/com.apple.e5rt.e5bundlecache,
33
- # which it marks APFS-purgeable, and macOS deletes those files whenever the disk runs low. The next change of length
34
- # then recompiles the model (3-36 s, 644 MB written again) or, when the purge lands mid-load, aborts the process with
35
- # "MPSGraphTensorData.mm: failed assertion `shape.count = 0 != strides.count = 2'". Measured on an M6 under load:
36
- # 1024 tokens cost ~34 ms a question at a steady length against ~23-32 ms for 384-768, while a change of length
37
- # cost 0.4-1.1 s at p50 and up to 18 s at p90. A lower floor (MU_LOCAL_JUDGE_MIN_LENGTH=256) is faster for short
38
- # requests and brings the changes of length back.
39
- DEFAULT_MIN_LENGTH = None
40
- DEFAULT_MODEL_DIR = Path(__file__).resolve().parent / "models" / "laya-multilingual-coreml"
41
-
42
-
43
- class BadRequest(ValueError):
44
- pass
45
-
46
-
47
- def setting(name, default):
48
- """`MU_<name>`, or the `KYRN_<name>` spelling from before the rename."""
49
- return os.environ.get(f"MU_{name}") or os.environ.get(f"KYRN_{name}") or default
50
-
51
-
52
- def _text(value):
53
- """Laya serializes non-string instructions with ASCII escapes, which mangles CJK. Do it here instead."""
54
- return value if isinstance(value, str) else json.dumps(value, ensure_ascii=False)
55
-
56
-
57
- def to_laya_question(question_id, question):
58
- if not isinstance(question, dict):
59
- raise BadRequest(f'Question "{question_id}" must be an object')
60
- kind = question.get("type")
61
- if "instructions" not in question:
62
- raise BadRequest(f'Question "{question_id}" is missing instructions')
63
- instructions = _text(question["instructions"])
64
- criteria = question.get("criteria")
65
-
66
- if kind == "boolean":
67
- out = {"type": "noul", "instructions": instructions}
68
- if isinstance(criteria, dict):
69
- kept = {key: criteria[key] for key in ("false", "true") if criteria.get(key) not in (None, "")}
70
- if kept:
71
- out["criteria"] = kept
72
- return out
73
- if kind == "choice":
74
- if not isinstance(criteria, dict) or not criteria:
75
- raise BadRequest(f'Choice question "{question_id}" needs a non-empty criteria object')
76
- return {"type": "choice", "instructions": instructions, "criteria": dict(criteria)}
77
- if kind == "score":
78
- if not isinstance(criteria, list) or len(criteria) < 2:
79
- raise BadRequest(f'Score question "{question_id}" needs at least two levels')
80
- return {
81
- "type": "score",
82
- "instructions": instructions,
83
- "criteria": ["" if level is None else level for level in criteria],
84
- }
85
- raise BadRequest(f'Question "{question_id}" has unknown type {kind!r}; expected boolean, choice or score')
86
-
87
-
88
- def from_laya_answer(answer):
89
- kind = answer["type"]
90
- if kind == "noul":
91
- return {"type": "boolean", "probability": answer["noul"]}
92
- if kind == "choice":
93
- return {"type": "choice", "choice": answer["choice"], "probabilities": answer["probabilities"]}
94
- return {"type": "score", "score": answer["score"], "probabilities": answer["probabilities"]}
95
-
96
-
97
- class LocalJudge:
98
- def __init__(self, model_dir, compute_units=None, min_length=DEFAULT_MIN_LENGTH):
99
- import laya_coreml as laya
100
-
101
- self.model_dir = Path(model_dir)
102
- started = time.perf_counter()
103
- self.agent = laya.load(str(self.model_dir), local_files_only=True, compute_units=compute_units)
104
- self.load_seconds = time.perf_counter() - started
105
- self.lock = threading.Lock()
106
- self.warm = False
107
- self.requests = 0
108
- self.started_at = time.time()
109
- manifest = getattr(self.agent, "manifest", {})
110
- self.model_id = "laya:" + str(manifest.get("repository") or self.model_dir.name)
111
- self.shape = getattr(self.agent, "shape", {})
112
- if min_length is None:
113
- min_length = getattr(self.agent, "cfg", {}).get("max_len") or self.shape.get("max_length") or 0
114
- self.min_length = int(min_length)
115
-
116
- def uniform_shape(self, state, laya_questions):
117
- """Pad every question of a request to one input length.
118
-
119
- Core ML re-specializes the GPU program whenever the enumerated length
120
- changes (0.3-1 s, against ~30 ms for a steady shape), reading the
121
- compiled model back from a cache macOS may have purged (see
122
- DEFAULT_MIN_LENGTH). The questions of one request differ in length
123
- because their options do, so one bucket per request removes the
124
- changes within it; the floor keeps every request in the same bucket.
125
- """
126
- lengths = self.agent.shape.get("lengths")
127
- if not lengths:
128
- return self.agent.shape
129
- items, _internal = self.agent.prepare(state, laya_questions)
130
- longest = max(max(len(item["ids"]) for item in items), self.min_length)
131
- target = next((n for n in lengths if n >= longest), lengths[-1])
132
- return dict(self.agent.shape, lengths=[target])
133
-
134
- def diagnostics(self, state, laya_questions):
135
- """Report what the token budget silently cut. Never raises."""
136
- warnings = []
137
- try:
138
- from laya_coreml.common import build_prefix, render_options, serialize_state
139
-
140
- tok, cfg = self.agent.tok, self.agent.cfg
141
- max_len, head_max_len = cfg.get("max_len", 512), cfg.get("head_max_len", 192)
142
- state_tokens = len(tok(serialize_state(state).replace(tok.mask_token, " "))["input_ids"])
143
- for question_id, definition in laya_questions.items():
144
- q = self.agent._to_internal(definition)
145
- prefix, _markers = build_prefix(tok, q, head_max_len)
146
- full_head = len(tok("%s question: %s" % (q["t"], q["ins"]))["input_ids"])
147
- full_options = sum(1 + len(tok(" " + option)["input_ids"]) for option in render_options(q))
148
- full_prefix = 1 + full_head + 1 + full_options + 1
149
- if len(prefix) < full_prefix:
150
- warnings.append(
151
- {
152
- "type": "question_truncated",
153
- "questionId": question_id,
154
- "message": f"question and options need {full_prefix} tokens, {len(prefix)} were kept",
155
- }
156
- )
157
- room = max(0, max_len - len(prefix) - 1)
158
- if state_tokens > room:
159
- warnings.append(
160
- {
161
- "type": "state_truncated",
162
- "questionId": question_id,
163
- "message": f"state has {state_tokens} tokens, only the first {room} were read",
164
- }
165
- )
166
- except Exception as error: # noqa: BLE001 - diagnostics are best effort
167
- warnings.append({"type": "diagnostics_failed", "message": type(error).__name__})
168
- return warnings
169
-
170
- def evaluate(self, state, questions):
171
- if not isinstance(questions, dict) or not questions:
172
- raise BadRequest("questions must be a non-empty object keyed by question id")
173
- if not isinstance(state, (str, dict, list)):
174
- raise BadRequest("state must be a string, an object or an array")
175
- laya_questions = {qid: to_laya_question(qid, q) for qid, q in questions.items()}
176
-
177
- started = time.perf_counter()
178
- with self.lock:
179
- waited = time.perf_counter() - started
180
- original_shape = self.agent.shape
181
- try:
182
- self.agent.shape = self.uniform_shape(state, laya_questions)
183
- result = self.agent.predict(state, laya_questions)
184
- except ValueError as error:
185
- raise BadRequest(str(error)) from error
186
- finally:
187
- self.agent.shape = original_shape
188
- self.requests += 1
189
- elapsed = time.perf_counter() - started
190
-
191
- metadata = {
192
- qid: {"confidence": answer.get("confidence"), "actProbability": answer.get("action", {}).get("act_probability")}
193
- for qid, answer in result["answers"].items()
194
- }
195
- usage = result.get("usage", {})
196
- print(
197
- f"[{time.strftime('%H:%M:%S')}] evaluate questions={len(questions)} "
198
- f"tokens={usage.get('input_tokens', 0)} ms={elapsed * 1000:.0f} waited_ms={waited * 1000:.0f}",
199
- file=sys.stderr,
200
- flush=True,
201
- )
202
- return {
203
- "answers": {qid: from_laya_answer(answer) for qid, answer in result["answers"].items()},
204
- "usage": {"inputTokens": usage.get("input_tokens", 0), "outputTokens": 0},
205
- "warnings": self.diagnostics(state, laya_questions),
206
- "providerMetadata": {"laya": {"model": self.model_id, "latencyMs": round(elapsed * 1000, 1), "answers": metadata}},
207
- }
208
-
209
- def warm_up(self):
210
- """Core ML compiles each enumerated input length on first use (seconds). Pay that before real traffic."""
211
- lengths = list(self.shape.get("lengths") or [self.shape.get("default_length", 128)])
212
- # Requests are padded to at least the floor's bucket, so no request uses a shorter one: warm only the lengths
213
- # traffic can reach, and finish on the floor's bucket, so the first request does not pay a shape switch.
214
- resting = next((n for n in lengths if n >= self.min_length), lengths[-1])
215
- lengths = [n for n in lengths if n > resting] + [resting]
216
- question = {"warm": {"type": "noul", "instructions": "Is this a warm-up request?"}}
217
- started = time.perf_counter()
218
- for length in lengths:
219
- # "a " is roughly one token; the prefix takes about 30, so this lands in the bucket for `length`.
220
- state = "a " * max(1, length - 40)
221
- try:
222
- with self.lock:
223
- original_shape = self.agent.shape
224
- self.agent.shape = dict(original_shape, lengths=[length])
225
- try:
226
- self.agent.predict(state, question)
227
- finally:
228
- self.agent.shape = original_shape
229
- except Exception as error: # noqa: BLE001 - a failed warm-up only costs latency later
230
- print(f"warm-up for length {length} failed: {type(error).__name__}", file=sys.stderr, flush=True)
231
- self.warm = True
232
- print(f"warm-up finished in {time.perf_counter() - started:.1f}s ({len(lengths)} shapes)", file=sys.stderr, flush=True)
233
-
234
- def health(self):
235
- return {
236
- "status": "ok" if self.warm else "warming",
237
- "model": self.model_id,
238
- "computeUnits": getattr(self.agent, "compute_units", None),
239
- "maxLength": self.shape.get("max_length"),
240
- "maxOptions": self.shape.get("max_options"),
241
- "batchSize": self.shape.get("batch_size"),
242
- "requests": self.requests,
243
- "uptimeSeconds": round(time.time() - self.started_at),
244
- "pid": os.getpid(),
245
- }
246
-
247
-
248
- def make_handler(judge):
249
- class Handler(BaseHTTPRequestHandler):
250
- server_version = "mu-local-judge/0.1"
251
-
252
- def log_message(self, *_args):
253
- pass
254
-
255
- def send_json(self, status, payload):
256
- body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
257
- self.send_response(status)
258
- self.send_header("Content-Type", "application/json; charset=utf-8")
259
- self.send_header("Content-Length", str(len(body)))
260
- self.end_headers()
261
- self.wfile.write(body)
262
-
263
- def send_failure(self, status, message):
264
- self.send_json(status, {"error": {"message": message}})
265
-
266
- def do_GET(self):
267
- if self.path == "/health":
268
- self.send_json(200, judge.health())
269
- else:
270
- self.send_failure(404, "Not found")
271
-
272
- def do_POST(self):
273
- if self.path != "/evaluate":
274
- return self.send_failure(404, "Not found")
275
- # Browsers always send Origin on cross-site POSTs; nothing legitimate calls this from a web page.
276
- if self.headers.get("Origin"):
277
- return self.send_failure(403, "Browser requests are not allowed")
278
- if "application/json" not in (self.headers.get("Content-Type") or ""):
279
- return self.send_failure(415, "Content-Type must be application/json")
280
- try:
281
- length = int(self.headers.get("Content-Length") or 0)
282
- except ValueError:
283
- return self.send_failure(400, "Invalid Content-Length")
284
- if length <= 0 or length > MAX_BODY_BYTES:
285
- return self.send_failure(413, f"Body must be between 1 and {MAX_BODY_BYTES} bytes")
286
- try:
287
- payload = json.loads(self.rfile.read(length))
288
- except (ValueError, UnicodeDecodeError):
289
- return self.send_failure(400, "Body is not valid JSON")
290
- if not isinstance(payload, dict):
291
- return self.send_failure(400, "Body must be a JSON object")
292
- try:
293
- self.send_json(200, judge.evaluate(payload.get("state"), payload.get("questions")))
294
- except BadRequest as error:
295
- self.send_failure(400, str(error)[:300])
296
- except Exception as error: # noqa: BLE001 - the kernel fails open on a 500
297
- print(f"evaluate failed: {type(error).__name__}: {error}", file=sys.stderr, flush=True)
298
- self.send_failure(500, f"Local judge failed: {type(error).__name__}")
299
-
300
- return Handler
301
-
302
-
303
- def main():
304
- parser = argparse.ArgumentParser(description="mu local judge sidecar (Laya on Core ML)")
305
- parser.add_argument("--model-dir", default=setting("LOCAL_JUDGE_MODEL", str(DEFAULT_MODEL_DIR)))
306
- parser.add_argument("--host", default=DEFAULT_HOST)
307
- parser.add_argument("--port", type=int, default=int(setting("LOCAL_JUDGE_PORT", DEFAULT_PORT)))
308
- parser.add_argument("--compute-units", choices=["all", "cpu", "cpu_gpu", "cpu_ne"])
309
- parser.add_argument("--no-warmup", action="store_true")
310
- parser.add_argument(
311
- "--min-length",
312
- type=int,
313
- default=setting("LOCAL_JUDGE_MIN_LENGTH", DEFAULT_MIN_LENGTH),
314
- help="Smallest input length to pad to. Default: the checkpoint's window, so the length never changes after "
315
- "warm-up; a change of length is slow and, when macOS has purged Core ML's cache, can crash the sidecar.",
316
- )
317
- args = parser.parse_args()
318
-
319
- if args.host not in ("127.0.0.1", "localhost", "::1"):
320
- parser.error("The local judge has no authentication and only binds to loopback")
321
-
322
- judge = LocalJudge(args.model_dir, compute_units=args.compute_units, min_length=args.min_length)
323
- server = ThreadingHTTPServer((args.host, args.port), make_handler(judge))
324
- server.daemon_threads = True
325
- print(
326
- f"mu local judge: {judge.model_id} loaded in {judge.load_seconds:.1f}s, "
327
- f"listening on http://{args.host}:{args.port}",
328
- file=sys.stderr,
329
- flush=True,
330
- )
331
- if args.no_warmup:
332
- judge.warm = True
333
- else:
334
- threading.Thread(target=judge.warm_up, daemon=True).start()
335
- try:
336
- server.serve_forever()
337
- except KeyboardInterrupt:
338
- pass
339
- finally:
340
- server.server_close()
341
-
342
-
343
- if __name__ == "__main__":
344
- main()