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 +176 -0
- package/NOTICE +19 -0
- package/README.md +108 -0
- package/bin/setup.mjs +297 -0
- package/client.js +436 -0
- package/cordis.patch.yml +9 -0
- package/index.js +870 -0
- package/install.mjs +38 -0
- package/package.json +49 -0
- package/service/finetuned_judge.py +324 -0
- package/service/intent_parser.py +1012 -0
- package/service/laya/__init__.py +51 -0
- package/service/laya/agent.py +447 -0
- package/service/laya/common.py +280 -0
- package/service/laya/email.py +90 -0
- package/service/laya/lang.py +324 -0
- package/service/laya/presets.py +187 -0
- package/service/laya/pyproject.toml +38 -0
- package/service/laya/router.py +447 -0
- package/service/laya_router.py +320 -0
- package/service/requirements.lock.txt +27 -0
- package/service/start_router.ps1 +102 -0
- package/service/start_router.sh +144 -0
- package/weights/fetch.mjs +219 -0
- package/weights/manifest.json +36 -0
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
|
+

|
|
40
|
+
|
|
41
|
+
负责判断的 7 题微调分类头,对金标(三模型交叉标注)一致率 99%、本地判定不到 1 秒:
|
|
42
|
+
|
|
43
|
+

|
|
44
|
+
|
|
45
|
+
会话内自动升档的实测阶梯——重试两轮,low 到 max:
|
|
46
|
+
|
|
47
|
+

|
|
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.");
|