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.
- package/CHANGELOG.md +14 -0
- package/README.md +3 -3
- package/judge/dist/auth.js +56 -8
- package/judge/dist/mude.js +38 -21
- package/judge/dist/setup.js +44 -27
- package/judge/manifest.json +15 -28
- package/judge/mu/index.js +953 -540
- package/kyrn/bin/kyrn-doctor +11 -25
- package/kyrn/bin/mu.mjs +84 -106
- package/package.json +2 -2
- package/kyrn/bin/kyrn-judge-local +0 -160
- package/kyrn/local-judge/requirements.lock.txt +0 -26
- package/kyrn/local-judge/server.py +0 -344
|
@@ -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()
|