dsh-router-laya 2.1.0

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,320 @@
1
+ """Laya judgment oracle for model routing.
2
+
3
+ Loads the checkpoint once, then answers one task per stdin line as JSON, so a caller pays the
4
+ ~70 s load a single time instead of once per decision.
5
+
6
+ .venv\\Scripts\\python.exe routing/laya_router.py < tasks.jsonl
7
+ .venv\\Scripts\\python.exe routing/laya_router.py --selftest
8
+
9
+ Input, one JSON object per line: {"id": <anything>, "task": "<text>"}
10
+ Output, one JSON object per line: one judgment, echoing `id`.
11
+
12
+ This file is deliberately **policy-free**: it reports what Laya thinks (difficulty, domain,
13
+ tool/sensitivity flags, each with its calibrated confidence) and stops there. Which model a
14
+ judgment maps to is the caller's business, so routing policy can change without editing this
15
+ file. CLI: --selftest runs two tasks through the real path and asserts the output contract.
16
+
17
+ Env: LAYA_MODEL (repo or local dir), LAYA_SUBFOLDER (multilingual|typed-decisions), LAYA_DEVICE.
18
+ """
19
+ import json
20
+ import os
21
+ import re
22
+ import sys
23
+ import time
24
+
25
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
26
+
27
+ # Piped stdio uses the locale codec (cp936 here), which would mangle non-ASCII task text on the
28
+ # way in and raise UnicodeEncodeError on the way out. This is a UTF-8 protocol, so say so.
29
+ sys.stdin.reconfigure(encoding="utf-8")
30
+ sys.stdout.reconfigure(encoding="utf-8")
31
+ sys.stderr.reconfigure(encoding="utf-8", errors="replace")
32
+
33
+ os.environ.setdefault("TOKENIZERS_PARALLELISM", "false")
34
+ os.environ.setdefault("USE_TF", "0")
35
+ os.environ.setdefault("USE_TORCH", "1")
36
+
37
+ import laya # noqa: E402
38
+
39
+ QUESTIONS = laya.router_questions()
40
+
41
+ # noul answers the "yes" probability; difficulty is an expectation over its 0-3 legend.
42
+ LEVELS = ["trivial", "easy", "moderate", "hard"]
43
+
44
+ # On `difficulty_confidence`: it is normalized-entropy peakedness (1 - H/log k), not P(correct).
45
+ # Measured on this checkpoint, real tasks land at 0.10-0.22 -- a score answer spread over two
46
+ # adjacent levels is normal, not ignorance -- so there is deliberately no "only act above N"
47
+ # threshold here. Calibrating one is the experiment's job, not a guess made in this file.
48
+
49
+ _PLACEHOLDER = re.compile(r"`([A-Za-z_][A-Za-z0-9_]*)`")
50
+
51
+
52
+ def required_state_keys(questions):
53
+ """State fields a preset names in its instructions, e.g. `request` or `message`.
54
+
55
+ These presets address the state by field name, so the state must be a dict carrying those
56
+ keys. Passing a bare string leaves the placeholder unbound and the model answers a question
57
+ about nothing -- confidently-shaped output, near-uniform probabilities, no error. Cheap to
58
+ check, and the alternative is a router that silently misroutes.
59
+ """
60
+ keys = set()
61
+ for q in questions.values():
62
+ keys |= set(_PLACEHOLDER.findall(str(q.get("instructions", ""))))
63
+ return keys
64
+
65
+
66
+ REQUIRED_KEYS = required_state_keys(QUESTIONS)
67
+
68
+
69
+ def decide(agent, task):
70
+ """One task -> one judgment dict. Mirrors the question ids in `laya.router_questions()`."""
71
+ state = {"request": task}
72
+ missing = REQUIRED_KEYS - set(state)
73
+ if missing:
74
+ raise ValueError("%s names state fields %s that the state does not carry"
75
+ % ("router_questions()", sorted(missing)))
76
+
77
+ t0 = time.time()
78
+ out = agent.predict(state, QUESTIONS)
79
+ a = out["answers"]
80
+
81
+ difficulty = a["difficulty"]
82
+ level = max(0, min(len(LEVELS) - 1, int(round(difficulty["score"]))))
83
+ domain = a["domain"]
84
+
85
+ return {
86
+ "difficulty": difficulty["score"],
87
+ "difficulty_level": level,
88
+ "difficulty_label": LEVELS[level],
89
+ "difficulty_confidence": difficulty["confidence"],
90
+ "difficulty_probs": difficulty["probabilities"],
91
+ "domain": domain["choice"],
92
+ "domain_confidence": domain["confidence"],
93
+ "needs_tools": a["needs_tools"]["noul"],
94
+ "needs_tools_confidence": a["needs_tools"]["confidence"],
95
+ "is_sensitive": a["is_sensitive"]["noul"],
96
+ "is_sensitive_confidence": a["is_sensitive"]["confidence"],
97
+ "laya_input_tokens": out["usage"]["input_tokens"],
98
+ "laya_ms": round((time.time() - t0) * 1000),
99
+ }
100
+
101
+
102
+ def selftest():
103
+ """Smallest check that catches breakage: the contract, through the real model path."""
104
+ agent = laya.load(os.environ.get("LAYA_MODEL", "convaiinnovations/laya"),
105
+ device=os.environ.get("LAYA_DEVICE"),
106
+ subfolder=os.environ.get("LAYA_SUBFOLDER"))
107
+ # Checked as a pair on purpose: absolute difficulty floors would assert my guess about the
108
+ # model's judgement rather than a property of the code. Ordering is the property that matters.
109
+ cases = [
110
+ ("rename the variable i to index in utils.py", "code"),
111
+ ("Design a distributed rate limiter that survives a region failover.", "code"),
112
+ ("客户投诉说发票金额不对,要求今天退款并升级到主管。", None), # non-ASCII round trip
113
+ ]
114
+ required = {"difficulty", "difficulty_level", "difficulty_label", "difficulty_probs", "domain",
115
+ "needs_tools", "is_sensitive", "laya_input_tokens", "laya_ms"}
116
+ bad, seen = [], []
117
+
118
+ # The state-key binding is hardcoded in `decide`; this catches a preset rename upstream that
119
+ # would silently unbind it again.
120
+ if REQUIRED_KEYS != {"request"}:
121
+ bad.append("router_questions() now names %s, not just {'request'}; `decide` needs updating"
122
+ % sorted(REQUIRED_KEYS))
123
+
124
+ for task, want_domain in cases:
125
+ j = decide(agent, task)
126
+ seen.append(j)
127
+ missing = required - set(j)
128
+ if missing:
129
+ bad.append("missing keys %s" % sorted(missing))
130
+ if not 0.0 <= j["difficulty"] <= float(len(LEVELS) - 1):
131
+ bad.append("difficulty out of range: %r" % j["difficulty"])
132
+ if not 0.0 <= j["needs_tools"] <= 1.0 or not 0.0 <= j["is_sensitive"] <= 1.0:
133
+ bad.append("noul probability out of range: %r" % j)
134
+ if j["difficulty_label"] != LEVELS[j["difficulty_level"]]:
135
+ bad.append("label %r disagrees with level %r" % (j["difficulty_label"], j["difficulty_level"]))
136
+ if want_domain and j["domain"] != want_domain:
137
+ print(" note: domain %r (expected %r) for %r" % (j["domain"], want_domain, task[:40]))
138
+ print(" %-8s d=%.2f %-14s conf=%.2f tools=%.2f sens=%.2f %sms"
139
+ % (j["difficulty_label"], j["difficulty"], j["domain"],
140
+ j["difficulty_confidence"], j["needs_tools"], j["is_sensitive"], j["laya_ms"]))
141
+ # The regression this file was written against: with the `request` placeholder unbound, every
142
+ # task rounded to the same level (1.54 and 1.76 -- both "moderate"). A level apart, not merely
143
+ # a higher float, is the property the router depends on.
144
+ if seen[1]["difficulty"] <= seen[0]["difficulty"]:
145
+ bad.append("difficulty did not increase from the trivial task (%.2f) to the hard one (%.2f)"
146
+ % (seen[0]["difficulty"], seen[1]["difficulty"]))
147
+ if seen[1]["difficulty_level"] <= seen[0]["difficulty_level"]:
148
+ bad.append("trivial and hard round to the same level %r (%.2f vs %.2f), so the router would "
149
+ "send both to the same model"
150
+ % (seen[0]["difficulty_label"], seen[0]["difficulty"], seen[1]["difficulty"]))
151
+ if bad:
152
+ print("FAIL: " + "; ".join(bad))
153
+ return 1
154
+ print("contract ok: %d cases" % len(cases))
155
+ return 0
156
+
157
+
158
+ def serve():
159
+ agent = None
160
+ for line in sys.stdin:
161
+ line = line.strip()
162
+ if not line:
163
+ continue
164
+ try:
165
+ req = json.loads(line)
166
+ task = req["task"]
167
+ except Exception as e: # one bad line must not kill the stream
168
+ print(json.dumps({"error": "bad request: %s" % e}), flush=True)
169
+ continue
170
+ if agent is None: # load lazily so a malformed first line still reports as an error
171
+ agent = laya.load(os.environ.get("LAYA_MODEL", "convaiinnovations/laya"),
172
+ device=os.environ.get("LAYA_DEVICE"),
173
+ subfolder=os.environ.get("LAYA_SUBFOLDER"))
174
+ print("router ready", file=sys.stderr, flush=True)
175
+ try:
176
+ j = decide(agent, task)
177
+ except Exception as e:
178
+ j = {"error": "%s: %s" % (type(e).__name__, e)}
179
+ j["id"] = req.get("id")
180
+ print(json.dumps(j, ensure_ascii=False), flush=True)
181
+
182
+
183
+ def http_serve(port=8765):
184
+ """HTTP mode: POST /judge {"task": "...", "prev_tier": ..., "prev_task": ...} -> judgment JSON.
185
+
186
+ 协议自动检测:
187
+ LAYA_MODEL 是本地目录 → 微调协议(7 noul 含 Q7 + 意图解析 + 规则引擎含规则0 + C3 升级 → tier)
188
+ LAYA_MODEL 是仓库 id → 基座协议(4分类 difficulty + domain 等)
189
+ """
190
+ from http.server import HTTPServer, BaseHTTPRequestHandler
191
+
192
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
193
+
194
+ model_path = os.environ.get(
195
+ "LAYA_MODEL",
196
+ # VENDORED DIFF (dsh-router-laya npm package): in the packaged layout the checkpoint is
197
+ # fetched by `node weights/fetch.mjs` into <package>/weights/model, so the default points
198
+ # there instead of the source checkout's <repo>/training/laya_router_finetuned. The
199
+ # package launchers (service/start_router.*) set LAYA_MODEL explicitly anyway.
200
+ os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
201
+ "weights", "model"),
202
+ )
203
+ is_finetuned = os.path.isdir(model_path)
204
+
205
+ if is_finetuned:
206
+ from finetuned_judge import load as ft_load, judge as ft_judge
207
+ agent = ft_load(model_path, device=os.environ.get("LAYA_DEVICE"))
208
+ protocol = "finetuned"
209
+ print("Laya router loaded [微调协议], HTTP on :%d" % port, file=sys.stderr, flush=True)
210
+ print(" model: %s" % model_path, file=sys.stderr, flush=True)
211
+ else:
212
+ agent = laya.load(model_path,
213
+ device=os.environ.get("LAYA_DEVICE"),
214
+ subfolder=os.environ.get("LAYA_SUBFOLDER"))
215
+ protocol = "base"
216
+ print("Laya router loaded [基座协议], HTTP on :%d" % port, file=sys.stderr, flush=True)
217
+ print(" model: %s" % model_path, file=sys.stderr, flush=True)
218
+
219
+ # Per-session judgment log for the frontend tier chip (plugin v2 frontend, GET /state).
220
+ # session_id -> deque of the last 20 judgments {ts, tier, triggered_by, regenerate, ms, task}.
221
+ judge_log = {}
222
+
223
+ class Handler(BaseHTTPRequestHandler):
224
+ def _cors(self):
225
+ """Allow the DSH web page to read this service from another port.
226
+
227
+ The tier chip runs in the browser on :3080 and polls GET /state here on :8765. A different
228
+ port is a different origin, so without these headers the browser blocks the response and the
229
+ chip can never see a judgment -- spec §2's "前端只管读" is not reachable without them.
230
+ Restricted to loopback origins: this service holds task text, so a wildcard would let any
231
+ page in the user's browser read their prompts.
232
+ """
233
+ origin = self.headers.get("Origin")
234
+ if origin and (origin.startswith("http://127.0.0.1:") or origin.startswith("http://localhost:")):
235
+ self.send_header("Access-Control-Allow-Origin", origin)
236
+ self.send_header("Vary", "Origin")
237
+
238
+ def do_POST(self):
239
+ if self.path != "/judge":
240
+ self.send_error(404)
241
+ return
242
+ length = int(self.headers.get("Content-Length", 0))
243
+ body = json.loads(self.rfile.read(length) or b"{}")
244
+ task = body.get("task", "")
245
+ prev_tier = body.get("prev_tier") # 上一轮档位,inherit/升级用
246
+ prev_task = body.get("prev_task") # 上一轮任务文本,regenerate 检测用(plugin v2)
247
+ session_id = body.get("session_id") or "default"
248
+ try:
249
+ if protocol == "finetuned":
250
+ j = ft_judge(agent, task, prev_tier=prev_tier, prev_task=prev_task)
251
+ else:
252
+ j = decide(agent, task)
253
+ j["id"] = body.get("id")
254
+ j["protocol"] = protocol
255
+ except Exception as e:
256
+ j = {"error": "%s: %s" % (type(e).__name__, e), "protocol": protocol}
257
+ if j.get("tier"):
258
+ from collections import deque
259
+ log = judge_log.setdefault(session_id, deque(maxlen=20))
260
+ log.append({
261
+ "ts": time.time(),
262
+ "tier": j["tier"],
263
+ "triggered_by": j.get("triggered_by", ""),
264
+ "regenerate": bool(j.get("regenerate")),
265
+ "ms": j.get("ms"),
266
+ "task": (task or "")[:60],
267
+ })
268
+ payload = json.dumps(j, ensure_ascii=False).encode("utf-8")
269
+ self.send_response(200)
270
+ self.send_header("Content-Type", "application/json; charset=utf-8")
271
+ self.send_header("Content-Length", str(len(payload)))
272
+ self.end_headers()
273
+ self.wfile.write(payload)
274
+
275
+ def do_GET(self):
276
+ """GET /health 返回协议信息;GET /state 返回各会话最近判定(前端档位芯片用)。"""
277
+ if self.path == "/state":
278
+ payload = json.dumps(
279
+ {"sessions": {k: list(v) for k, v in judge_log.items()},
280
+ "protocol": protocol},
281
+ ensure_ascii=False).encode("utf-8")
282
+ self.send_response(200)
283
+ self.send_header("Content-Type", "application/json; charset=utf-8")
284
+ self.send_header("Content-Length", str(len(payload)))
285
+ self._cors()
286
+ self.end_headers()
287
+ self.wfile.write(payload)
288
+ return
289
+ if self.path != "/health":
290
+ self.send_error(404)
291
+ return
292
+ info = {"protocol": protocol, "model": model_path,
293
+ "finetuned": is_finetuned, "status": "ok"}
294
+ payload = json.dumps(info, ensure_ascii=False).encode("utf-8")
295
+ self.send_response(200)
296
+ self.send_header("Content-Type", "application/json; charset=utf-8")
297
+ self.send_header("Content-Length", str(len(payload)))
298
+ self.end_headers()
299
+ self.wfile.write(payload)
300
+
301
+ def log_message(self, fmt, *args):
302
+ pass # quiet
303
+
304
+ server = HTTPServer(("127.0.0.1", port), Handler)
305
+ print("Ready. POST http://127.0.0.1:%d/judge GET /health" % port,
306
+ file=sys.stderr, flush=True)
307
+ server.serve_forever()
308
+
309
+
310
+ if __name__ == "__main__":
311
+ if "--selftest" in sys.argv:
312
+ sys.exit(selftest())
313
+ if "--http" in sys.argv:
314
+ port = 8765
315
+ for i, a in enumerate(sys.argv):
316
+ if a == "--port" and i + 1 < len(sys.argv):
317
+ port = int(sys.argv[i + 1])
318
+ http_serve(port)
319
+ else:
320
+ serve()
@@ -0,0 +1,27 @@
1
+ # dsh-router-laya :: pinned Python dependencies for the vendored judge service (service/).
2
+ #
3
+ # pip install -r service/requirements.lock.txt
4
+ #
5
+ # torch is pinned to the CPU wheel (`2.14.0+cpu`). That local-version wheel is NOT on PyPI -- it
6
+ # only exists on PyTorch's own CPU index, so this file pulls it in via --extra-index-url (NOT
7
+ # --index-url, which would replace PyPI and break every other line below):
8
+ #
9
+ # --extra-index-url https://download.pytorch.org/whl/cpu
10
+ #
11
+ # GPU users: swap the torch line for a CUDA build instead of the +cpu pin -- e.g. the cu124 wheel
12
+ # from https://download.pytorch.org/whl/cu124 (or the plain PyPI `torch==2.14.0`, which bundles a
13
+ # CUDA runtime). The service picks its device automatically at startup (cuda > mps > cpu), so no
14
+ # other change is needed; set LAYA_DEVICE to force one.
15
+ #
16
+ # The `laya` package itself is deliberately NOT listed here: a vendored copy (laya 0.3.7, carrying
17
+ # the 7-question routing protocol this plugin's finetuned checkpoint was trained for) ships next
18
+ # to this file under service/laya/ and is what the service imports. Installing the PyPI `laya`
19
+ # into the same environment would shadow or diverge from it -- do not add it.
20
+
21
+ --extra-index-url https://download.pytorch.org/whl/cpu
22
+ torch==2.14.0+cpu
23
+ transformers==5.17.0
24
+ safetensors==0.8.0
25
+ huggingface_hub==1.32.0
26
+ numpy>=2.0,<3
27
+ PyYAML>=6.0,<7
@@ -0,0 +1,102 @@
1
+ # Idempotent launcher for the dsh-router-laya judge service -- the package's Windows twin of
2
+ # service/start_router.sh. Same behaviour as the source repo's routing/start_router.ps1: skip
3
+ # when /health already answers, wait up to 60s for the model load, log the child to %TEMP%.
4
+ #
5
+ # powershell -File service/start_router.ps1 [-Port 8765] [-Device cpu]
6
+ #
7
+ # Environment: LAYA_PYTHON, LAYA_MODEL, LAYA_DEVICE, LAYA_START_TIMEOUT (seconds, default 60).
8
+ # Resolution:
9
+ # python: $env:LAYA_PYTHON -> <package>\.venv-router\Scripts\python.exe -> nearest .venv (dev) -> "python"
10
+ # model: $env:LAYA_MODEL -> <package>\weights\model -> nearest training\laya_router_finetuned (dev)
11
+ param(
12
+ [int]$Port = 8765,
13
+ [string]$Device = ""
14
+ )
15
+ $ErrorActionPreference = "Stop"
16
+ $pkg = Split-Path -Parent $PSScriptRoot
17
+ $script = Join-Path $pkg "service\laya_router.py"
18
+ $healthUrl = "http://127.0.0.1:$Port/health"
19
+ $timeoutSec = 60
20
+ if ($env:LAYA_START_TIMEOUT) { $timeoutSec = [int]$env:LAYA_START_TIMEOUT }
21
+ $outLog = Join-Path $env:TEMP "laya-router-service.out.log"
22
+ $errLog = Join-Path $env:TEMP "laya-router-service.err.log"
23
+
24
+ try {
25
+ $health = Invoke-RestMethod -Uri $healthUrl -TimeoutSec 2
26
+ if ($health.protocol -eq "finetuned") {
27
+ Write-Host "[start_router] already running on :$Port (protocol=finetuned) -- skip"
28
+ exit 0
29
+ }
30
+ Write-Host "[start_router] WARNING: something answers :$Port but protocol=$($health.protocol), not finetuned"
31
+ } catch {
32
+ # not running -> start it below
33
+ }
34
+
35
+ function Find-Up([string]$marker) {
36
+ # Walk up from $pkg looking for a relative marker path; returns its absolute path or $null.
37
+ $dir = $pkg
38
+ for ($i = 0; $i -lt 6 -and $dir; $i++) {
39
+ $candidate = Join-Path $dir $marker
40
+ if (Test-Path $candidate) { return $candidate }
41
+ $parent = Split-Path -Parent $dir
42
+ if (-not $parent -or $parent -eq $dir) { break }
43
+ $dir = $parent
44
+ }
45
+ return $null
46
+ }
47
+
48
+ # python
49
+ $py = $null
50
+ if ($env:LAYA_PYTHON -and (Test-Path $env:LAYA_PYTHON)) {
51
+ $py = $env:LAYA_PYTHON
52
+ } else {
53
+ $venvRouter = Join-Path $pkg ".venv-router\Scripts\python.exe"
54
+ if (Test-Path $venvRouter) { $py = $venvRouter }
55
+ else {
56
+ $devVenv = Find-Up ".venv\Scripts\python.exe" # dev checkout: the repo's own venv
57
+ if ($devVenv) { $py = $devVenv } else { $py = "python" }
58
+ }
59
+ }
60
+
61
+ # checkpoint
62
+ if ($env:LAYA_MODEL) {
63
+ $model = $env:LAYA_MODEL
64
+ } else {
65
+ $packaged = Join-Path $pkg "weights\model\model.safetensors"
66
+ if (Test-Path $packaged) { $model = Join-Path $pkg "weights\model" }
67
+ else { $model = Find-Up "training\laya_router_finetuned" }
68
+ }
69
+ if (-not $model) {
70
+ Write-Host "[start_router] no checkpoint found: run 'node weights/fetch.mjs' to fill $pkg\weights\model," -ForegroundColor Red
71
+ Write-Host "[start_router] or set LAYA_MODEL to an existing laya_router_finetuned directory." -ForegroundColor Red
72
+ exit 2
73
+ }
74
+ if (-not (Test-Path $script)) {
75
+ Write-Host "[start_router] service script missing: $script" -ForegroundColor Red
76
+ exit 2
77
+ }
78
+
79
+ $env:PYTHONIOENCODING = "utf-8"
80
+ if ($Device -ne "") { $env:LAYA_DEVICE = $Device }
81
+
82
+ Write-Host "[start_router] launching service/laya_router.py --http on :$Port (model: $model)"
83
+ $proc = Start-Process -FilePath $py `
84
+ -ArgumentList "`"$script`"","--http","--port",$Port `
85
+ -WorkingDirectory $pkg -WindowStyle Hidden -PassThru `
86
+ -RedirectStandardOutput $outLog `
87
+ -RedirectStandardError $errLog
88
+
89
+ # wait for /health (model load: seconds on GPU, ~70s on CPU)
90
+ $deadline = (Get-Date).AddSeconds($timeoutSec)
91
+ while ((Get-Date) -lt $deadline) {
92
+ Start-Sleep -Milliseconds 500
93
+ try {
94
+ $health = Invoke-RestMethod -Uri $healthUrl -TimeoutSec 2
95
+ if ($health.protocol -eq "finetuned") {
96
+ Write-Host "[start_router] ready on :$Port (protocol=finetuned, pid=$($proc.Id))"
97
+ exit 0
98
+ }
99
+ } catch {}
100
+ }
101
+ Write-Host "[start_router] service did not become healthy in ${timeoutSec}s -- see $errLog"
102
+ exit 1
@@ -0,0 +1,144 @@
1
+ #!/usr/bin/env bash
2
+ # Idempotent launcher for the dsh-router-laya judge service -- the bash twin of the package's
3
+ # start_router.ps1 (which mirrors routing/start_router.ps1's behaviour: skip when /health already
4
+ # answers, wait up to 60s for the model load, log the child's output to temp files).
5
+ #
6
+ # bash service/start_router.sh [--port 8765] [--device cpu] [--dry-run]
7
+ #
8
+ # Environment:
9
+ # LAYA_PYTHON python executable to use (default: resolved below)
10
+ # LAYA_MODEL checkpoint directory (default: resolved below)
11
+ # LAYA_DEVICE "cpu" | "cuda" | "mps" (auto-detected when unset)
12
+ # LAYA_START_TIMEOUT health-wait seconds (default 60; CPU cold loads can take ~70s)
13
+ #
14
+ # Resolution order:
15
+ # python: $LAYA_PYTHON -> <package>/.venv-router/{Scripts/python.exe,bin/python} -> python3/python on PATH
16
+ # model: $LAYA_MODEL -> <package>/weights/model -> nearest training/laya_router_finetuned (dev checkout)
17
+ set -u
18
+
19
+ PORT=8765
20
+ DEVICE=""
21
+ DRY_RUN=0
22
+ while [ $# -gt 0 ]; do
23
+ case "$1" in
24
+ --port) PORT="$2"; shift 2 ;;
25
+ --device) DEVICE="$2"; shift 2 ;;
26
+ --dry-run) DRY_RUN=1; shift ;;
27
+ *) echo "[start_router] unknown argument: $1 (usage: [--port N] [--device cpu] [--dry-run])" >&2; exit 2 ;;
28
+ esac
29
+ done
30
+
31
+ PKG="$(cd "$(dirname "$0")/.." && pwd)"
32
+ SCRIPT="$PKG/service/laya_router.py"
33
+ HEALTH_URL="http://127.0.0.1:$PORT/health"
34
+ TIMEOUT="${LAYA_START_TIMEOUT:-60}"
35
+ LOG_DIR="${TMPDIR:-/tmp}"
36
+ OUT_LOG="$LOG_DIR/laya-router-service.out.log"
37
+ ERR_LOG="$LOG_DIR/laya-router-service.err.log"
38
+
39
+ # native_path: MSYS/Git Bash paths (/e/...) mean nothing to Windows Python; convert when possible.
40
+ native_path() {
41
+ if command -v cygpath >/dev/null 2>&1; then
42
+ cygpath -m "$1"
43
+ else
44
+ realpath "$1" 2>/dev/null || printf '%s' "$1"
45
+ fi
46
+ }
47
+
48
+ health() {
49
+ if ! command -v curl >/dev/null 2>&1; then
50
+ echo "[start_router] curl is required for the /health probe (or set LAYA_PYTHON and use start_router.ps1)" >&2
51
+ exit 2
52
+ fi
53
+ curl -s -m 2 "$HEALTH_URL" 2>/dev/null
54
+ }
55
+
56
+ # ── idempotence: already up (and speaking the finetuned protocol) -> nothing to do ──────────
57
+ HEALTH_JSON="$(health || true)"
58
+ if printf '%s' "$HEALTH_JSON" | grep -q '"protocol": *"finetuned"'; then
59
+ echo "[start_router] already running on :$PORT (protocol=finetuned) -- skip"
60
+ exit 0
61
+ fi
62
+ if [ -n "$HEALTH_JSON" ]; then
63
+ echo "[start_router] WARNING: something answers :$PORT but not with protocol=finetuned ($HEALTH_JSON)"
64
+ fi
65
+
66
+ # ── resolve python ───────────────────────────────────────────────────────────────────────────
67
+ # Each candidate is probed with a real import AND the >= 3.10 floor (the WindowsApps `python3`
68
+ # alias and older PATH pythons are executable but cannot run this service).
69
+ PY=""
70
+ probe_python() { "$1" -c "import sys; assert sys.version_info >= (3, 10)" >/dev/null 2>&1; }
71
+ if [ -n "${LAYA_PYTHON:-}" ] && [ -x "${LAYA_PYTHON:-}" ] && probe_python "$LAYA_PYTHON"; then
72
+ PY="$LAYA_PYTHON"
73
+ else
74
+ for cand in "$PKG/.venv-router/Scripts/python.exe" "$PKG/.venv-router/bin/python" \
75
+ "$(command -v python3 2>/dev/null)" "$(command -v python 2>/dev/null)"; do
76
+ if [ -n "$cand" ] && [ -x "$cand" ] && probe_python "$cand"; then PY="$cand"; break; fi
77
+ done
78
+ fi
79
+ if [ -z "$PY" ]; then
80
+ echo "[start_router] no python found -- run 'node bin/setup.mjs' first, or set LAYA_PYTHON" >&2
81
+ exit 2
82
+ fi
83
+
84
+ # ── resolve the checkpoint and the service script ────────────────────────────────────────────
85
+ if [ -n "${LAYA_MODEL:-}" ]; then
86
+ MODEL="$LAYA_MODEL"
87
+ elif [ -f "$PKG/weights/model/model.safetensors" ]; then
88
+ MODEL="$(native_path "$PKG/weights/model")"
89
+ else
90
+ # Dev-checkout fallback: walk up to the source repo whose training/ dir holds the checkpoint.
91
+ MODEL=""
92
+ dir="$PKG"
93
+ for _ in 1 2 3 4 5 6; do
94
+ if [ -f "$dir/training/laya_router_finetuned/model.safetensors" ]; then
95
+ MODEL="$(native_path "$dir/training/laya_router_finetuned")"
96
+ break
97
+ fi
98
+ parent="$(dirname "$dir")"
99
+ [ "$parent" = "$dir" ] && break
100
+ dir="$parent"
101
+ done
102
+ fi
103
+ if [ -z "$MODEL" ]; then
104
+ echo "[start_router] no checkpoint found: run 'node weights/fetch.mjs' to fill $PKG/weights/model," >&2
105
+ echo "[start_router] or set LAYA_MODEL to an existing laya_router_finetuned directory." >&2
106
+ exit 2
107
+ fi
108
+ if [ ! -f "$SCRIPT" ]; then
109
+ echo "[start_router] service script missing: $SCRIPT" >&2
110
+ exit 2
111
+ fi
112
+
113
+ if [ "$DRY_RUN" = "1" ]; then
114
+ echo "[start_router] dry-run: would launch on :$PORT"
115
+ echo " python : $PY"
116
+ echo " script : $SCRIPT"
117
+ echo " model : $MODEL"
118
+ echo " device : ${DEVICE:-${LAYA_DEVICE:-auto}}"
119
+ echo " health : $HEALTH_URL (wait up to ${TIMEOUT}s)"
120
+ echo " logs : $OUT_LOG / $ERR_LOG"
121
+ exit 0
122
+ fi
123
+
124
+ export PYTHONIOENCODING=utf-8
125
+ [ -n "$DEVICE" ] && export LAYA_DEVICE="$DEVICE"
126
+
127
+ echo "[start_router] launching service/laya_router.py --http on :$PORT (model: $MODEL)"
128
+ cd "$PKG" || exit 2
129
+ nohup "$PY" "$SCRIPT" --http --port "$PORT" >"$OUT_LOG" 2>"$ERR_LOG" &
130
+ PID=$!
131
+ disown "$PID" 2>/dev/null || true
132
+
133
+ # ── wait for /health (model load: seconds on GPU, ~70s on CPU) ───────────────────────────────
134
+ deadline=$(( $(date +%s) + TIMEOUT ))
135
+ while [ "$(date +%s)" -lt "$deadline" ]; do
136
+ sleep 0.5
137
+ HEALTH_JSON="$(health || true)"
138
+ if printf '%s' "$HEALTH_JSON" | grep -q '"protocol": *"finetuned"'; then
139
+ echo "[start_router] ready on :$PORT (protocol=finetuned, pid=$PID)"
140
+ exit 0
141
+ fi
142
+ done
143
+ echo "[start_router] service did not become healthy in ${TIMEOUT}s -- see $ERR_LOG" >&2
144
+ exit 1