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.
package/LICENSE ADDED
@@ -0,0 +1,176 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
package/NOTICE ADDED
@@ -0,0 +1,19 @@
1
+ dsh-router-laya
2
+ Copyright 2026 the dsh-router-laya authors
3
+
4
+ This product includes software developed as part of the dsh-router-laya project
5
+ and its development repository HapyRain/layaDemo, licensed under the Apache License, Version 2.0.
6
+
7
+ This product vendored and redistributes, in unmodified form, portions of the following
8
+ third-party projects, both licensed under the Apache License, Version 2.0:
9
+
10
+ * convaiinnovations/laya -- https://huggingface.co/convaiinnovations/laya
11
+ Vendored as service/laya/ (package source, laya 0.3.7, carrying the 7-question routing
12
+ protocol this plugin's finetuned checkpoint was trained for).
13
+
14
+ * answerdotai/ModernBERT-large -- https://huggingface.co/answerdotai/ModernBERT-large
15
+ The encoder backbone of the fine-tuned judge checkpoint (laya-router-7q) that
16
+ weights/fetch.mjs downloads; its weights are embedded in model.safetensors.
17
+
18
+ The fine-tuned checkpoint itself (laya-router-7q) is a derivative work of the two projects
19
+ above and is likewise distributed under the Apache License, Version 2.0.
package/README.md ADDED
@@ -0,0 +1,108 @@
1
+ # dsh-router-laya
2
+
3
+ 一个 DSH 插件:对没有明确档位指令的消息,自动选择思考档(low / high / max)。
4
+
5
+ 做法是在本地跑一个微调过的分类模型(842MB,CPU 推理不到 1 秒),配合会话内的重试检测自动升档。
6
+ 判定服务挂了会自动降级,任务文本不出本机。
7
+
8
+ ## 为什么做它——以及为什么值得你试试
9
+
10
+ 你大概也有同感:想让 AI 在难题上多想一会儿,就得手动把档位拉满;而大多数日常消息,
11
+ 拉满纯属浪费——多烧的 token 换不来更好的回答,来回手动切又麻烦,忘了切就一直烧。
12
+
13
+ 这件事不是我们拍脑袋,业界已经有定论:
14
+
15
+ - **DeepSeek 的 V3.2 技术报告专门写了这个机制**(thinking budget):模型训练时就按
16
+ 「在给定的思考 token 预算内最大化准确率」来优化,报告里附了不同预算下的性能曲线。
17
+ 所谓档位,对应的其实就是这份预算——档位高,能思考的 token 多,消耗也大。
18
+ - **OpenAI 的 o3-mini 直接按 low/medium/high 三档发布官方成绩**:低档在多数任务上已经够用,
19
+ 高档只在高难任务上拉开差距([官方公告](https://openai.com/index/o3-mini/))。
20
+ - **《Thoughtology》综述对 R1 系模型的测量**:强模型的准确率随思考预算增长很快饱和,
21
+ 大多数任务用不满大预算,多出来的基本是白烧。
22
+ - Qwen3 的按请求预算、Anthropic 的 budget_tokens、LangChain 的 reasoning_effort——
23
+ 「档位 ≈ 思考 token 预算帽」已经是行业通行的实现。
24
+
25
+ 既然档位是真实存在的旋钮,问题只剩一个:**谁来判断该拧到几档?** 手动切麻烦,全局拉满浪费。
26
+ dsh-router-laya 把这个判断交给一个本地小模型:它先看一眼你的消息,决定需要多少思考,
27
+ 再把请求路由到合适的档位——你只管发消息。具体来说:
28
+
29
+ - **省钱**:简单消息走 low,思考 token 只有拉满时的零头,不再为简单问题支付推理成本
30
+ - **快**:低档首字响应更快;本地判定不到 1 秒,无感
31
+ - **难事不掉链子**:任务复杂或你在重试时自动升档,max 兜底
32
+ - **隐私**:判定模型本地跑,任务文本不出本机
33
+ - **越用越准**:判定模型可以随你的使用习惯持续重训
34
+
35
+ 我们另做了 120 多次本地对照实验交叉验证,方向与公开结论一致:单任务 low 档约 96% 成功
36
+ (确实不必多花钱);15 个任务连吃 low 档 42% 翻车、升档后约 17%——这就是这个插件要吃的
37
+ 那一段:
38
+
39
+ ![会话失败率 vs 任务数](docs/charts/chart-why.svg)
40
+
41
+ 负责判断的 7 题微调分类头,对金标(三模型交叉标注)一致率 99%、本地判定不到 1 秒:
42
+
43
+ ![判定质量](docs/charts/chart-accuracy.svg)
44
+
45
+ 会话内自动升档的实测阶梯——重试两轮,low 到 max:
46
+
47
+ ![升档实测](docs/charts/chart-ladder.svg)
48
+
49
+ 输入栏有个档位芯片,实时显示当前档位和最近 20 轮的判断原因;服务离线时变灰并给出启动命令。
50
+
51
+ ## 安装
52
+
53
+ ```bash
54
+ npm i -g dsh-router-laya
55
+ npx dsh-router-laya setup
56
+ ```
57
+
58
+ setup 做三件事:建 Python 虚拟环境、下载 846MB 的 checkpoint(走本仓 GitHub Release,
59
+ HF 和镜像做兜底,支持断点续传)、启动判定服务。然后按它打印的 snippet 在你的 DSH profile
60
+ 里注册插件行,设两个环境变量:
61
+
62
+ ```
63
+ ROUTEEXP_ARM=auto
64
+ ROUTEEXP_RESPECT_EXPLICIT=1
65
+ ```
66
+
67
+ 要求:Node ≥ 18,Python ≥ 3.10,约 2GB 磁盘。判定服务需要常驻(setup 会启动,重启机器后
68
+ 重跑 `npx dsh-router-laya setup` 或 service 里的启动脚本)。
69
+
70
+ ## 它怎么决定档位
71
+
72
+ 按顺序过四层,任何一层命中就停:
73
+
74
+ 1. 词典意图:「用最高档」→ 直接定档(一票否决);「继续」→ 保持上轮;「别用 max」→ 记一个约束
75
+ 2. 微调模型:7 个布尔题(副作用 / 跨模块 / 步骤依赖 / 深推理 / 代码 / 生成 / 会话复利)→ 规则引擎出档
76
+ 3. 升级:这轮消息是上一轮的重试 → 沿阶梯升一级
77
+ 4. 约束过滤:第 1 层记下的约束最后统一执行
78
+
79
+ 失败路径:判定服务不可达 → 落 low,会话不断。
80
+
81
+ ## 已知边界
82
+
83
+ - 判定耗时 1–3 秒,计入每轮首字延迟
84
+ - 服务需要常驻进程;DSH 大版本更新可能改动前端注入缝,client.js 需要跟着适配
85
+ - 权重下载在国内网络走镜像兜底,首次 846MB
86
+
87
+ ## 数据与复现
88
+
89
+ README 里的图由 `scripts/make_charts.py`(纯 stdlib)从实测数据生成,CI 会检查图表是否过期。
90
+ 实验全记录(试筛、判读规则、标注方案、验收数据)在主开发仓
91
+ [HapyRain/layaDemo](https://github.com/HapyRain/layaDemo) 的 docs/ 目录。
92
+
93
+ ## 写在最后
94
+
95
+ 这个插件目前还在尝试阶段,有不少没做完的地方。当前实现的判断逻辑,针对日常使用是够用的;
96
+ 如果你要把它用在大项目、高难度任务,或者对识别率有更高要求,建议基于你自己的使用习惯重新
97
+ 微调一版模型——标注、训练、验收的整条链都是现成的(见上面的数据与复现),换一批你自己的
98
+ 语料就能重训,本机有 GPU 的话几分钟一轮。
99
+
100
+ 我自己后面有精力的话,也会再用新语料把 Laya 重新微调一版。这次做得比较仓促,见谅。
101
+
102
+ 这本身只是一个小思路。哪里不对、哪里可以更好,欢迎在 GitHub Issues 里提,我都会看。
103
+
104
+ ## License
105
+
106
+ 本项目代码为 Apache-2.0。判定模型基于 [convaiinnovations/laya](https://huggingface.co/convaiinnovations/laya)
107
+ 与 [answerdotai/ModernBERT-large](https://huggingface.co/answerdotai/ModernBERT-large) 两个
108
+ Apache-2.0 项目微调而来,按协议要求在 [NOTICE](./NOTICE) 中署名,在此向两个上游项目致谢。
package/bin/setup.mjs ADDED
@@ -0,0 +1,297 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `npx dsh-router-laya setup` -- one-shot bootstrap for the judge service the plugin routes with.
4
+ *
5
+ * Four steps, fail-fast, each with a fix hint, and safe to re-run (idempotent):
6
+ *
7
+ * a. python >= 3.10 -> create `.venv-router` -> pip install -r service/requirements.lock.txt
8
+ * b. node weights/fetch.mjs (the ~846 MB checkpoint; --skip-weights to skip)
9
+ * c. print the plugin registration guide (profiles found, verbatim patch snippet, env vars)
10
+ * d. start the judge service and wait for /health (reuses service/start_router.*)
11
+ *
12
+ * It deliberately does NOT edit any cordis/profile config: step c prints the exact snippet for
13
+ * the user to paste (docs/marketplace-review.md §4 -- an installer that rewrites the user's patch
14
+ * file is how that file rotted twice before, docs/HANDOFF-20260925.md §5.1).
15
+ *
16
+ * Flags:
17
+ * --dry-run print every step's plan, execute nothing (python probe and reads aside)
18
+ * --skip-weights skip step b (weights already in place, or served another way)
19
+ * --skip-venv skip step a entirely (no venv, no pip; launch uses LAYA_PYTHON/PATH python)
20
+ * --help this text
21
+ *
22
+ * Environment: LAYA_PYTHON, LAYA_DEVICE, LAYA_ROUTER_URL (port the service binds), HF_ENDPOINT,
23
+ * LAYA_START_TIMEOUT (health-wait seconds passed to the launcher, default 180 here -- CPU cold
24
+ * loads take ~70s, and the launcher's own default of 60s is tuned for the warm dev flow).
25
+ */
26
+ import { spawnSync } from 'node:child_process';
27
+ import fs from 'node:fs';
28
+ import os from 'node:os';
29
+ import path from 'node:path';
30
+ import { fileURLToPath } from 'node:url';
31
+
32
+ const here = path.dirname(fileURLToPath(import.meta.url)); // <package>/bin
33
+ const pkg = path.dirname(here);
34
+ const VENV_DIR = path.join(pkg, '.venv-router');
35
+ const LOCK_FILE = path.join(pkg, 'service', 'requirements.lock.txt');
36
+ const FETCH_SCRIPT = path.join(pkg, 'weights', 'fetch.mjs');
37
+ const SERVICE_SCRIPT = path.join(pkg, 'service', 'laya_router.py');
38
+
39
+ const args = process.argv.slice(2);
40
+ const dryRun = args.includes('--dry-run');
41
+ const skipWeights = args.includes('--skip-weights');
42
+ const skipVenv = args.includes('--skip-venv');
43
+ const help = args.includes('--help') || args.includes('-h');
44
+
45
+ if (help) {
46
+ console.log(`usage: npx dsh-router-laya setup [--dry-run] [--skip-weights] [--skip-venv]
47
+
48
+ a. python >= 3.10 -> .venv-router -> pip install -r service/requirements.lock.txt
49
+ b. node weights/fetch.mjs (checkpoint download, ~846 MB, resumable, sha256-verified)
50
+ c. print plugin registration guide (no config is edited -- the snippet is for you to paste)
51
+ d. start the judge service + /health poll
52
+
53
+ env: LAYA_PYTHON, LAYA_DEVICE, LAYA_ROUTER_URL (default http://127.0.0.1:8765/judge),
54
+ HF_ENDPOINT, LAYA_START_TIMEOUT (default 180s for this script's cold-start path)`);
55
+ process.exit(0);
56
+ }
57
+
58
+ function fail(step, message, hint) {
59
+ console.error(`\n[setup] step ${step} FAILED: ${message}`);
60
+ if (hint) console.error(`[setup] fix: ${hint}`);
61
+ process.exit(1);
62
+ }
63
+
64
+ function note(message) {
65
+ console.log(`[setup] ${message}`);
66
+ }
67
+
68
+ // ── step a: python + venv + pinned requirements ──────────────────────────────────────────────
69
+
70
+ function pythonCandidates() {
71
+ const cands = [];
72
+ if (process.env.LAYA_PYTHON) cands.push({ cmd: process.env.LAYA_PYTHON, base: [] });
73
+ if (process.platform === 'win32') cands.push({ cmd: 'py', base: ['-3'] });
74
+ cands.push({ cmd: 'python3', base: [] }, { cmd: 'python', base: [] });
75
+ return cands;
76
+ }
77
+
78
+ function probePython(cand) {
79
+ const code = 'import sys; print(".".join(map(str, sys.version_info[:3]))); print(sys.executable)';
80
+ const r = spawnSync(cand.cmd, [...cand.base, '-c', code], { encoding: 'utf8', timeout: 15000 });
81
+ if (r.status !== 0 || !r.stdout) return null;
82
+ const [version, exe] = r.stdout.trim().split(/\r?\n/);
83
+ const parts = version.split('.').map((n) => parseInt(n, 10));
84
+ if (parts.length < 2 || parts.some(Number.isNaN)) return null;
85
+ return { version, exe: exe || cand.cmd, major: parts[0], minor: parts[1] };
86
+ }
87
+
88
+ const venvPython = process.platform === 'win32'
89
+ ? path.join(VENV_DIR, 'Scripts', 'python.exe')
90
+ : path.join(VENV_DIR, 'bin', 'python');
91
+
92
+ function stepA() {
93
+ console.log('\n== step a: python >= 3.10 + .venv-router + pinned requirements ==');
94
+ if (skipVenv) {
95
+ note('--skip-venv: not creating a venv; launch will use LAYA_PYTHON or PATH python.');
96
+ return;
97
+ }
98
+
99
+ let probe = null;
100
+ for (const cand of pythonCandidates()) {
101
+ probe = probePython(cand);
102
+ if (probe) {
103
+ note(`python ${probe.version} at ${probe.exe}`);
104
+ break;
105
+ }
106
+ }
107
+ if (!probe) {
108
+ fail('a', 'no usable python interpreter found',
109
+ 'install Python 3.10-3.13 from python.org (3.12 recommended) and re-run; '
110
+ + 'or point LAYA_PYTHON at an existing interpreter');
111
+ }
112
+ if (probe.major < 3 || (probe.major === 3 && probe.minor < 10)) {
113
+ fail('a', `python ${probe.version} is too old (need >= 3.10, 3.12 recommended)`,
114
+ 'install a newer Python from python.org, or point LAYA_PYTHON at one');
115
+ }
116
+
117
+ if (fs.existsSync(venvPython)) {
118
+ note(`venv already exists: ${VENV_DIR}`);
119
+ } else {
120
+ note(`creating venv: ${probe.exe} -m venv ${VENV_DIR}`);
121
+ if (!dryRun) {
122
+ const r = spawnSync(probe.exe, [...probe.base, '-m', 'venv', VENV_DIR], { stdio: 'inherit' });
123
+ if (r.status !== 0 || !fs.existsSync(venvPython)) {
124
+ fail('a', `venv creation exited ${r.status}`,
125
+ 'on Debian/Ubuntu install python3-venv first; then re-run setup');
126
+ }
127
+ }
128
+ }
129
+
130
+ // The marker records the lock file's mtime, so editing requirements.lock.txt invalidates it.
131
+ const marker = path.join(VENV_DIR, '.laya-reqs-ok');
132
+ const lockStamp = String(fs.statSync(LOCK_FILE).mtimeMs);
133
+ if (fs.existsSync(marker) && fs.readFileSync(marker, 'utf8') === lockStamp) {
134
+ note('requirements already installed (marker matches requirements.lock.txt)');
135
+ return;
136
+ }
137
+ const pipCmd = `${venvPython} -m pip install -r ${LOCK_FILE}`;
138
+ note(pipCmd);
139
+ if (!dryRun) {
140
+ const r = spawnSync(venvPython, ['-m', 'pip', 'install', '-r', LOCK_FILE], { stdio: 'inherit' });
141
+ if (r.status !== 0) {
142
+ fail('a', `pip install exited ${r.status}`,
143
+ 'check the pip output above (proxy? disk space?); GPU users: swap the torch line in '
144
+ + 'service/requirements.lock.txt for a CUDA wheel, then re-run');
145
+ }
146
+ fs.writeFileSync(marker, lockStamp);
147
+ }
148
+ }
149
+
150
+ // ── step b: checkpoint download ───────────────────────────────────────────────────────────────
151
+
152
+ function stepB() {
153
+ console.log('\n== step b: judge checkpoint (~846 MB, once) ==');
154
+ if (skipWeights) {
155
+ note('--skip-weights: leaving weights/ as-is.');
156
+ return;
157
+ }
158
+ const manifest = JSON.parse(fs.readFileSync(path.join(pkg, 'weights', 'manifest.json'), 'utf8'));
159
+ note(`repo ${manifest.repo}@${manifest.revision}, ${manifest.files.length} files, `
160
+ + `${(manifest.totalBytes / 1e6).toFixed(0)} MB into weights/model/`);
161
+ note('endpoints: $HF_ENDPOINT (if set) -> https://huggingface.co -> https://hf-mirror.com');
162
+ if (!dryRun) {
163
+ const r = spawnSync(process.execPath, [FETCH_SCRIPT], { stdio: 'inherit' });
164
+ if (r.status !== 0) {
165
+ fail('b', `weights fetch exited ${r.status}`,
166
+ 'set HF_ENDPOINT to a reachable mirror and re-run; or place the files into '
167
+ + 'weights/model/ by hand (names + sha256 in weights/manifest.json)');
168
+ }
169
+ }
170
+ }
171
+
172
+ // ── step c: registration guide (read-only; prints, never edits) ──────────────────────────────
173
+
174
+ function servicePythonForSnippet() {
175
+ if (fs.existsSync(venvPython)) return venvPython;
176
+ if (process.env.LAYA_PYTHON) return process.env.LAYA_PYTHON;
177
+ const probe = pythonCandidates().map(probePython).find(Boolean);
178
+ return probe ? probe.exe : '<python>';
179
+ }
180
+
181
+ function stepC() {
182
+ console.log('\n== step c: register the plugin (guidance only -- nothing is edited) ==');
183
+ const profilesDir = path.join(os.homedir(), '.dsh', 'profiles');
184
+ if (fs.existsSync(profilesDir)) {
185
+ const profiles = fs.readdirSync(profilesDir, { withFileTypes: true })
186
+ .filter((d) => d.isDirectory()).map((d) => d.name);
187
+ note(`profiles under ${profilesDir}: ${profiles.length ? profiles.join(', ') : '(none)'}`);
188
+ note("each profile's module root is ~/.dsh/profiles/<name>/node_modules -- register there.");
189
+ } else {
190
+ note(`no profiles directory at ${profilesDir} -- run 'dsh' once to create one, then re-run.`);
191
+ }
192
+ const py = servicePythonForSnippet();
193
+ // Single-quoted YAML scalars treat backslashes literally -- emit Windows paths verbatim.
194
+ console.log(`
195
+ Paste this row into the profile's cordis patch (verbatim; only add, never rewrite the file):
196
+
197
+ - insert:
198
+ - id: router-laya
199
+ name: 'dsh-router-laya'
200
+ config:
201
+ auto: true
202
+ servicePython: '${py}'
203
+ serviceScript: '${SERVICE_SCRIPT}'
204
+
205
+ `);
206
+ note('servicePython/serviceScript above let the plugin auto-start the judge (config.autoStart '
207
+ + 'defaults on; set autoStart: false to opt out).');
208
+ note('environment switches, read by the plugin at apply() time (a row config cannot set them):');
209
+ note(' ROUTEEXP_ARM=auto -- enables auto mode when the row omits `auto: true`');
210
+ note(' ROUTEEXP_RESPECT_EXPLICIT=1 -- requests with an explicit effort keep it (recommended)');
211
+ }
212
+
213
+ // ── step d: start the service + /health poll ──────────────────────────────────────────────────
214
+
215
+ function judgeBase() {
216
+ const raw = process.env.LAYA_ROUTER_URL || 'http://127.0.0.1:8765/judge';
217
+ return String(raw).replace(/\/judge\/?$/, '');
218
+ }
219
+
220
+ async function healthOk(base, timeoutMs = 2000) {
221
+ try {
222
+ const res = await fetch(`${base}/health`, { signal: AbortSignal.timeout(timeoutMs) });
223
+ if (!res.ok) return null;
224
+ const info = await res.json();
225
+ return info && info.protocol === 'finetuned' ? info : null;
226
+ } catch {
227
+ return null;
228
+ }
229
+ }
230
+
231
+ async function stepD() {
232
+ console.log('\n== step d: judge service + /health ==');
233
+ const base = judgeBase();
234
+ const port = new URL(base).port || '80';
235
+ const launcher = process.platform === 'win32'
236
+ ? { cmd: 'powershell.exe', args: ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File',
237
+ path.join(pkg, 'service', 'start_router.ps1'), '-Port', port] }
238
+ : { cmd: 'bash', args: [path.join(pkg, 'service', 'start_router.sh'), '--port', port] };
239
+ const env = { ...process.env, LAYA_START_TIMEOUT: process.env.LAYA_START_TIMEOUT || '180' };
240
+
241
+ if (dryRun) {
242
+ note(`dry-run: would probe ${base}/health, then run:`);
243
+ console.log(` ${launcher.cmd} ${launcher.args.join(' ')}`);
244
+ note(`then poll ${base}/health (protocol must be "finetuned").`);
245
+ return;
246
+ }
247
+
248
+ const up = await healthOk(base);
249
+ if (up) {
250
+ note(`already up at ${base} (protocol=finetuned, model=${up.model})`);
251
+ return;
252
+ }
253
+
254
+ note(`starting via ${path.basename(launcher.cmd)} launcher on :${port} ...`);
255
+ const r = spawnSync(launcher.cmd, launcher.args, { stdio: 'inherit', env });
256
+ if (r.error && r.error.code === 'ENOENT') {
257
+ fail('d', `${launcher.cmd} not found`,
258
+ process.platform === 'win32'
259
+ ? 'run powershell -File service/start_router.ps1 manually'
260
+ : 'install bash, or run: LAYA_PYTHON=<python> bash service/start_router.sh');
261
+ }
262
+ if (r.status !== 0) {
263
+ fail('d', `launcher exited ${r.status}`,
264
+ `read the service log (%TEMP%/laya-router-service.err.log or /tmp/laya-router-service.err.log); `
265
+ + 'a missing checkpoint or a broken pip install shows up here');
266
+ }
267
+
268
+ // The launcher already waited for /health; this re-probe is belt and braces for detached starts.
269
+ const deadline = Date.now() + 15000;
270
+ let info = null;
271
+ while (Date.now() < deadline) {
272
+ info = await healthOk(base, 1000);
273
+ if (info) break;
274
+ await new Promise((resolve) => setTimeout(resolve, 500));
275
+ }
276
+ if (!info) {
277
+ fail('d', `${base}/health never answered with protocol=finetuned`,
278
+ 'check the service log paths printed by the launcher');
279
+ }
280
+ note(`judge ready: ${base} (protocol=finetuned, model=${info.model})`);
281
+ }
282
+
283
+ // ── run ---------------------------------------------------------------------------------------
284
+
285
+ console.log(`dsh-router-laya setup -- package root: ${pkg}`);
286
+ if (dryRun) note('--dry-run: probing and printing only, nothing will be installed or started.');
287
+
288
+ stepA();
289
+ stepB();
290
+ stepC();
291
+ await stepD();
292
+
293
+ console.log('\n== setup done ==');
294
+ note('after registering the plugin row, restart dsh once (bundle layer needs a reboot, '
295
+ + 'patchReload only covers the user patch layer).');
296
+ note('sanity check: curl the /health URL printed above, then send a message and look for '
297
+ + "[router-laya] turn 1 auto ... -> low (laya) on the plugin's stderr.");