@mzzsfy/dsh-llm-pi-gateway 0.2.6 → 0.3.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/README.md +73 -29
- package/cordis.patch.yml +28 -4
- package/package.json +4 -2
- package/src/apply-state.mjs +25 -0
- package/src/guard.js +344 -0
- package/src/index.js +61 -13
- package/src/takeover.mjs +120 -0
- package/test/guard.test.mjs +324 -0
- package/test/host-compat.test.mjs +25 -11
- package/test/takeover-guard.test.mjs +286 -0
package/README.md
CHANGED
|
@@ -1,17 +1,63 @@
|
|
|
1
1
|
# @mzzsfy/dsh-llm-pi-gateway
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
把 DSH 会话的 sessionId 注入发往 newapi 等 LLM 网关的每个请求,供网关做请求亲和性(粘性会话路由):同一会话的请求稳定落到同一上游渠道,上游 prompt cache 命中是这种路由方式的结果。形态上是官方 `dsh-llm-pi-ai` 的零感知增强替换(装上即接管、卸载即还原),compat 全控、metadata 模板透传与静态 headers 兜底一并提供。
|
|
4
|
+
|
|
5
|
+
## sessionId 注入通道
|
|
6
|
+
|
|
7
|
+
核心能力:每条路由的每个上游请求自动携带会话标识,网关按任一载体做粘性键即可实现请求亲和性:
|
|
8
|
+
|
|
9
|
+
| 通道 | 载体 | 值 | 生效条件 |
|
|
10
|
+
| --- | --- | --- | --- |
|
|
11
|
+
| 请求体标记 | anthropic-messages 写 `metadata.user_id`(同 Claude Code);openai-completions / openai-responses 写顶层 `prompt_cache_key`(同 Codex,无 `prompt_cache_retention` 副作用) | sessionId 单向派生的稳定标记 `dsh:<sha256 前 40 位>`,前缀可配 | 默认开启,每路由可关(`sessionMarker.enabled`);不受 `cacheRetention` 影响 |
|
|
12
|
+
| 亲和头 | 按协议与 `sessionAffinityFormat`,精确映射见下 | 裸 sessionId 原样 | sessionId 在场且 `cacheRetention` ≠ `none`;anthropic-messages / openai-completions 另需 compat `sendSessionAffinityHeaders: true`,openai-responses 无需 compat |
|
|
13
|
+
| metadata 模板 | anthropic `metadata` 任意键透传(openai 系路由声明 metadata 无效果) | 字符串值渲染 `{sessionId}` / `{marker}` 占位符 | 依赖标记通道:非 user_id 键经标记 onPayload 合入请求体,`sessionMarker.enabled=false` 时仅 user_id 键经 pi-ai 原生转发,其余键不上 wire;模板 `user_id` 键标记开启时恒被派生标记覆盖 |
|
|
14
|
+
| 静态 headers | 任意网关约定的自定义头 | 固定值 | 声明即生效;与亲和头同名时静态头后写覆盖;请避开 attribution 保留头名 |
|
|
15
|
+
|
|
16
|
+
亲和头精确映射(头集合随协议与 `sessionAffinityFormat` 不同,网关粘性键须按所选形态勾选):
|
|
17
|
+
|
|
18
|
+
| 协议 | 头集合 |
|
|
19
|
+
| --- | --- |
|
|
20
|
+
| anthropic-messages | `x-session-affinity` |
|
|
21
|
+
| openai-completions | format `openai`:`session_id` + `x-client-request-id` + `x-session-affinity`;format `openai-nosession`:`x-client-request-id` + `x-session-affinity`;format `openrouter`:`x-session-id` |
|
|
22
|
+
| openai-responses | format `openai`:`session_id` + `x-client-request-id`;format `openai-nosession`:`x-client-request-id`;format `openrouter`:`x-session-id` |
|
|
23
|
+
|
|
24
|
+
format 未声明时由 pi-ai 自动检测:provider 名为 `openrouter`(精确匹配)或 baseUrl 含 `openrouter.ai` 判为 `openrouter`,否则 `openai`——newapi 类路由未声明即按 `openai` 头集合发射。
|
|
25
|
+
|
|
26
|
+
- 标记经 pi-ai `onPayload`(请求体发出前最后一步)直写,不依赖 baseURL / retention 条件,上游原生发射的同名键以本包值覆盖;路由按显式 api 直注,形状判别仅为 api 缺失时的兜底,未知形状不注入。
|
|
27
|
+
- 亲和头由 pi-ai 按路由 compat 与 `cacheRetention` 门控:`cacheRetention: none` 抑制全部亲和头(亲和头不再携带会话 id,请求体通道不受影响);`sessionAffinityFormat` 仅 openai 系 compat 名单内合法,取值 `openai` / `openai-nosession` / `openrouter`,声明其他值不报错但产生残缺头集合。
|
|
28
|
+
- sessionId 缺省契约:标记关闭且模板不引用 `{sessionId}` 的路由允许请求缺省 sessionId(官方 wire 契约同构);接管路由标记默认开启,sessionId 因此默认必填。
|
|
29
|
+
- 粘性生效前提:网关侧按对应键做亲和路由,粘性键勾选上述载体之一(`metadata.user_id` / `prompt_cache_key` / `x-session-affinity` / `x-client-request-id` / `session_id` / `x-session-id`,按路由协议与 format 取其一);本包只保证标识发出,网关行为不在边界内。
|
|
4
30
|
|
|
5
31
|
## 零感知接管(0.2.0 起)
|
|
6
32
|
|
|
7
33
|
**装上即生效,用户无感**:插件通过 bundle patch 禁用官方 `dsh-llm-pi-ai` 行,并以**官方包自己的 Config schema** 接管 `llm-pi-ai:` 设置节——
|
|
8
34
|
|
|
9
35
|
- 现有官方配置**原样保留、原样生效**,路由名不变:`agent-default-model`、quota 面板、Models 页面、历史会话全部无缝继续
|
|
10
|
-
-
|
|
36
|
+
- 接管的路由自动获得 sessionId 注入(请求体标记默认开启;亲和头 anthropic-messages / openai-completions 经 compat 开启,openai-responses 默认开启),其余行为与官方逐项对表(见下节)
|
|
11
37
|
- `llm-pi-gateway:` 节仍可用:独立声明路由,或与官方节同名时整体覆盖(增强)
|
|
12
38
|
- **卸载即还原**:patch 随 bundle 移除,官方插件恢复,同一份配置继续由官方服务
|
|
13
39
|
- 防御:若 patch 失效(宿主升级等)官方插件仍在,本包接管官方节失败时降级为只服务 `llm-pi-gateway:` 节并记日志(命名空间注册冲突与一般失败区分文案),不阻塞启动;官方包本体不可用时同样降级并告警
|
|
14
40
|
|
|
41
|
+
### 禁用与还原
|
|
42
|
+
|
|
43
|
+
宿主注册面(adapter/directory/discovery/settings 命名空间)全部排他,官方插件与本包无法共存,因此**官方行的禁用由本包 bundle patch 静态声明,不随本包行被禁用而失效**。
|
|
44
|
+
|
|
45
|
+
**dsh-market 开关 / 单行禁用(普通用户)**:直接在 dsh-market 点禁用,或只写一行 `- id: llm-pi-gateway` + `disabled: true`。bundle patch 会同时插入一个哨兵行(`llm-pi-gateway/guard`,market 的行写入对其 id 拒绝,不会被连带禁用):两行同禁的窗口期(点禁用后到下次重启)内,哨兵动态挂载官方插件恢复服务,所有模型继续可用;重启后本包 patch 随 bundle 移出(market 对本包按 disable-carrier 处理),官方插件原生接管,哨兵自动退场。重新点启用则哨兵先卸载,本包无缝接管回来。market 路径每次开关会对哨兵行记一条「行 id 含特殊字符,不支持写入补丁层」警告,属预期噪音。手写单行禁用(不移出 bundles)时,哨兵的自愈在每次重启后重新生效,死态窗口由哨兵长期兜底。
|
|
46
|
+
|
|
47
|
+
**补丁层两行禁用(高级用户,不重启即时生效)**:
|
|
48
|
+
|
|
49
|
+
```yaml
|
|
50
|
+
- id: llm-pi-gateway
|
|
51
|
+
disabled: true
|
|
52
|
+
- id: llm-pi-ai
|
|
53
|
+
disabled: false
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
- 官方行复活让位守卫:上述两行写入后热重载时,本包在官方插件重新注册前自停让位,官方无缝接管,不出现注册冲突拖垮 patch 应用;反之删掉这两行恢复接管时,本包等官方插件退场落定后再接管
|
|
57
|
+
- 完全卸载本包(`dsh plugin remove`)无需任何 patch 改动,patch 随 bundle 移除,官方行自动恢复
|
|
58
|
+
- 两节 provider 同名重叠时的归属:官方在场(让位态)下官方插件先注册,本包自有节若声明了与官方节同名的 provider,整组路由注册被宿主排他检查拒绝,本包节路由(含不重叠的)整体不服务,仅日志披露——让位态请避免两节同名,或接受官方节优先
|
|
59
|
+
- 官方行 `disabled` 写为 `!!js` 表达式且求值出错时,本包按未禁用让位(官方节归官方,不制造注册冲突);若官方行实际未启用,会出现双空闲(provider 路由无人服务),需修正表达式或改静态布尔
|
|
60
|
+
|
|
15
61
|
## 官方兼容
|
|
16
62
|
|
|
17
63
|
需要 dsh 本体 0.1.2 及以上:激活时动态探测 dsh-llm 的 `resolveImageAttachmentAccess` / `offloadedImageText`(0.1.2 引入),缺失即打日志禁用插件,不注册 adapter 与 settings 节,不影响宿主启动与其他插件。
|
|
@@ -61,13 +107,10 @@ DeepSeek Harness pi-ai 透传网关插件:官方 `dsh-llm-pi-ai` 的零感知增
|
|
|
61
107
|
| 模型发现 | `INVALID_CREDENTIAL` | 发现请求凭据被上游拒绝 |
|
|
62
108
|
| 模型发现 | `ABORTED` | 发现请求被调用方中止 |
|
|
63
109
|
|
|
64
|
-
##
|
|
110
|
+
## 其他功能
|
|
65
111
|
|
|
66
112
|
- **图片输入(多模态,官方管线同构)**:请求含图片且模型声明 `input: [text, image]` 时,经 attachments 服务读出为 base64 块(handle 文本 + `image` 块),预算策略 `maxRequestImageBytes` / `requestImagePixelBudget` / `requestImageMaxBytes` 与官方同款(缺省 20MiB / 4Mi 像素 / 1MiB);非 user 角色图片、模型无 image 能力、attachments 服务缺失均按官方语义 `UNSUPPORTED_CONTENT`;纯文本路径零开销。
|
|
67
|
-
-
|
|
68
|
-
- **compat 全控**:官方包 withhold 的粘性等字段全部开放(`sendSessionAffinityHeaders`、`sessionAffinityFormat`、`supportsDeveloperRole` 等),字段名按 pi-ai 0.84.4 各协议 compat 类型校验,值为 null 拒绝;模型级 compat 覆盖路由级。
|
|
69
|
-
- **metadata 模板透传**:字符串值支持 `{sessionId}` / `{marker}` 占位符;标记注入与模板独立,模板 `user_id` 键被标记覆盖,其余键照常透传。
|
|
70
|
-
- **静态 headers**:任意网关约定的兜底通道(请避开 attribution 保留头名)。
|
|
113
|
+
- **compat 全控**:官方包(0.1.2-rc.1)withhold 的字段全部开放(`supportsDeveloperRole` 等;`sendSessionAffinityHeaders` / `sessionAffinityFormat` 即上表亲和头通道),字段名按 pi-ai 0.84.4 各协议 compat 类型校验,值为 null 拒绝;模型级 compat 覆盖路由级。
|
|
71
114
|
- **多模型路由**:一条路由声明多个模型,按请求 model 字段分发,未命中返回 `UNKNOWN_MODEL`。
|
|
72
115
|
|
|
73
116
|
## 配置(settings.yaml 命名空间 `llm-pi-gateway`)
|
|
@@ -75,30 +118,28 @@ DeepSeek Harness pi-ai 透传网关插件:官方 `dsh-llm-pi-ai` 的零感知增
|
|
|
75
118
|
```yaml
|
|
76
119
|
llm-pi-gateway:
|
|
77
120
|
providers:
|
|
78
|
-
new-api:
|
|
79
|
-
displayName: New API
|
|
121
|
+
new-api-claude: # 路由名 = LLM provider 名
|
|
122
|
+
displayName: New API Claude # 可选,配置面/选择器显示名
|
|
80
123
|
api: anthropic-messages # 或 openai-completions / openai-responses
|
|
81
124
|
baseURL: https://newapi.example.com
|
|
82
125
|
apiKeyEnv: NEW_API_API_KEY # 凭据引用(credentials 服务或环境变量)
|
|
83
126
|
reasoning: high # 可选,路由级默认档位(模型需支持)
|
|
84
|
-
sessionMarker: #
|
|
127
|
+
sessionMarker: # 会话标记(请求体通道),默认开启
|
|
85
128
|
enabled: true
|
|
86
129
|
prefix: dsh # 派生标记前缀
|
|
87
|
-
metadata: # anthropic metadata 模板(可选)
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
130
|
+
metadata: # anthropic metadata 模板(可选);user_id 键由标记占用,勿在此声明
|
|
131
|
+
gateway: newapi
|
|
132
|
+
session: '{sessionId}'
|
|
133
|
+
compat: # 路由级 compat 覆盖,无 withhold;字段按协议名单校验
|
|
134
|
+
sendSessionAffinityHeaders: true # 亲和头通道(anthropic 即 x-session-affinity)
|
|
92
135
|
headers: # 静态自定义头(粘性兜底通道)
|
|
93
136
|
x-gateway-group: pool-a
|
|
94
|
-
cacheRetention: short # 可选:none / short / long
|
|
137
|
+
cacheRetention: short # 可选:none / short / long(none 会抑制亲和头)
|
|
95
138
|
defaultContextWindow: 262144 # 可选,模型未声明时的兜底
|
|
96
139
|
retryPolicy: # 可选,注册捕获,进 runtime 重试
|
|
97
140
|
mode: normal
|
|
98
141
|
maxRetries: 2
|
|
99
142
|
models:
|
|
100
|
-
- id: auto
|
|
101
|
-
contextWindow: 200000
|
|
102
143
|
- id: claude-sonnet
|
|
103
144
|
name: Claude Sonnet
|
|
104
145
|
input: [text, image]
|
|
@@ -108,20 +149,23 @@ llm-pi-gateway:
|
|
|
108
149
|
reasoningEfforts:
|
|
109
150
|
off:
|
|
110
151
|
low: low
|
|
152
|
+
high: high
|
|
111
153
|
max: ultra
|
|
112
154
|
compat:
|
|
113
155
|
sendSessionAffinityHeaders: true
|
|
156
|
+
new-api-gpt: # openai 系协议示例(sessionAffinityFormat 仅在此类协议合法)
|
|
157
|
+
api: openai-responses # 或 openai-completions
|
|
158
|
+
baseURL: https://newapi.example.com/v1
|
|
159
|
+
apiKeyEnv: NEW_API_API_KEY
|
|
160
|
+
compat:
|
|
161
|
+
sessionAffinityFormat: openai # openai / openai-nosession / openrouter,头集合见注入通道表
|
|
162
|
+
models:
|
|
114
163
|
- id: gpt-4o
|
|
115
|
-
compat:
|
|
116
|
-
sendSessionAffinityHeaders: true
|
|
117
|
-
sessionAffinityFormat: openai # openai 三头 / openrouter 单头 x-session-id
|
|
118
164
|
```
|
|
119
165
|
|
|
120
|
-
|
|
166
|
+
路由与模型的全量可配键以本包 settings schema 为准,示例未穷举。
|
|
121
167
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
网关侧需按对应键路由:newapi 粘性键勾选 `metadata.user_id` / `x-session-affinity` / `x-session-id` / `prompt_cache_key` 之一。本包只保证标识发出,网关行为不在边界内。
|
|
168
|
+
配置修改**即时生效**(无需重启);解析失败时保留上一份好配置。纯 host 端,无 GUI。
|
|
125
169
|
|
|
126
170
|
## 安装
|
|
127
171
|
|
|
@@ -129,16 +173,16 @@ llm-pi-gateway:
|
|
|
129
173
|
dsh plugin --profile web add @mzzsfy/dsh-llm-pi-gateway
|
|
130
174
|
```
|
|
131
175
|
|
|
132
|
-
重启 dsh 生效。**无需任何配置改动**:官方 `llm-pi-ai:` 节原样接管(官方 schema 消费)
|
|
176
|
+
重启 dsh 生效。**无需任何配置改动**:官方 `llm-pi-ai:` 节原样接管(官方 schema 消费),路由名不变,sessionId 注入自动开启(请求体标记)。`llm-pi-gateway:` 节仅用于增强覆盖(同名整体优先)或独立路由。
|
|
133
177
|
|
|
134
178
|
卸载插件 = 官方原样接管回来,同一份配置继续工作。
|
|
135
179
|
|
|
136
180
|
## 已知取舍
|
|
137
181
|
|
|
138
|
-
- 事件流适配与 pi-ai
|
|
182
|
+
- 事件流适配与 pi-ai 数据结构耦合;路由按显式 api 直注标记,形状判别仅为 api 缺失的兜底路径,未知形状不注入。
|
|
139
183
|
- 无流空闲超时看门狗(官方 0.1.2-rc.1 经 `streamIdleTimeoutMs` 300 秒兜底;实现需进程内动态 import dsh-timeout,宿主可达性待实测后补齐,当前纯披露);pi-ai 依赖下界较官方收紧(官方 0.1.2-rc.1 为 ^0.84.2,本包因 compat 名单取材 0.84.4 抬至 ^0.84.4),官方升级范围时本包需跟随。
|
|
140
|
-
- sessionMarker.enabled=false 不拦截 metadata 模板的静态 user_id 键透传(该键来自模板而非标记器,不含会话派生标识)
|
|
141
|
-
- `sessionId` 缺省契约:会话标记关闭且模板不引用 `{sessionId}` 的路由允许缺失(官方 wire 契约同构,缺省即省略该键);接管路由会话标记**默认开启**,sessionId
|
|
184
|
+
- sessionMarker.enabled=false 不拦截 metadata 模板的静态 user_id 键透传(该键来自模板而非标记器,不含会话派生标识);模板其余键经标记 onPayload 合入,标记关闭时不上 wire。标记关闭时 openai 系的原生 `prompt_cache_key` 随之不再被覆盖,pi-ai 原生行为仍在:openai-responses 在 retention ≠ none(未配置默认 short)时携带截断裸 sessionId;openai-completions 在 baseUrl 含 api.openai.com、或 retention 为 long 且 compat 允许时同——依赖派生标记隐藏内部会话 id 的场景此时应改用其他载体做粘性键。
|
|
185
|
+
- `sessionId` 缺省契约:会话标记关闭且模板不引用 `{sessionId}` 的路由允许缺失(官方 wire 契约同构,缺省即省略该键);接管路由会话标记**默认开启**,sessionId 因此默认必填——sessionId 缺省承诺仅对显式关闭标记且模板不引用 `{sessionId}` 的路由兑现。
|
|
142
186
|
- 上游错误以文本分类(pi-ai 把捕获错误展平为 message 字符串),quota/超窗判定用官方同款判定器,其余分支与官方同序同构。
|
|
143
187
|
- 本包未复用 dsh-llm 类(插件依赖以副本安装,class 身份不通;错误以 own `code` / `failure` 数据属性被 harness 错误边界识别),官方公共导出仅消费纯函数与 schema 对象。
|
|
144
188
|
- `reasoningEfforts` → `thinkingLevelMap` 解析为平行实现(官方未导出该函数),以对表测试锚定语义。
|
package/cordis.patch.yml
CHANGED
|
@@ -7,10 +7,32 @@
|
|
|
7
7
|
# marking; the `llm-pi-gateway` section stays available for standalone or
|
|
8
8
|
# enhanced routes. Uninstalling this bundle removes the patch, the official
|
|
9
9
|
# row re-enables, and the official plugin serves the same profiles again.
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
10
|
+
#
|
|
11
|
+
# Dead-state self-heal: the second inserted row (`llm-pi-gateway/guard`) is
|
|
12
|
+
# a sentinel. While BOTH the gateway row and the official row are disabled
|
|
13
|
+
# (e.g. the dsh-market toggle disables the gateway row instantly while its
|
|
14
|
+
# bundle removal only lands on the next boot), the sentinel dynamically
|
|
15
|
+
# mounts the official plugin so models keep working. When the gateway row
|
|
16
|
+
# comes back, the sentinel unmounts first and the gateway takes over with
|
|
17
|
+
# zero registration conflicts. The sentinel id contains a "/", which the
|
|
18
|
+
# dsh-market row writer refuses to persist, so the sentinel survives the
|
|
19
|
+
# market toggle and is the only live self-heal executor in that window.
|
|
20
|
+
#
|
|
21
|
+
# Escape hatch: bundle patches load unconditionally while the bundle stays
|
|
22
|
+
# listed, so disabling the gateway row ALONE would leave the official row
|
|
23
|
+
# disabled — the sentinel covers that window, and a restart removes this
|
|
24
|
+
# bundle's patch entirely (market uninstall or carrier toggle), restoring
|
|
25
|
+
# the official plugin natively. To hand control back WITHOUT restarting,
|
|
26
|
+
# also re-enable the official row:
|
|
27
|
+
#
|
|
28
|
+
# - id: llm-pi-gateway
|
|
29
|
+
# disabled: true
|
|
30
|
+
# - id: llm-pi-ai
|
|
31
|
+
# disabled: false
|
|
32
|
+
#
|
|
33
|
+
# The gateway yields on its own before the official plugin re-registers
|
|
34
|
+
# (revival guard), and re-takes-over only after the official plugin has
|
|
35
|
+
# exited, so the switch over is live-reload safe in both directions.
|
|
14
36
|
|
|
15
37
|
- id: llm-pi-ai
|
|
16
38
|
disabled: true
|
|
@@ -18,3 +40,5 @@
|
|
|
18
40
|
- insert:
|
|
19
41
|
- id: llm-pi-gateway
|
|
20
42
|
name: '@mzzsfy/dsh-llm-pi-gateway'
|
|
43
|
+
- id: llm-pi-gateway/guard
|
|
44
|
+
name: '@mzzsfy/dsh-llm-pi-gateway/guard'
|
package/package.json
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mzzsfy/dsh-llm-pi-gateway",
|
|
3
|
-
"description": "NewAPI 等 LLM
|
|
4
|
-
"version": "0.
|
|
3
|
+
"description": "NewAPI 等 LLM 网关接入:向每个上游请求注入会话 sessionId(请求体派生标记 + 亲和头裸 sessionId),供网关做请求亲和性粘性路由,compat 全开放",
|
|
4
|
+
"version": "0.3.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
7
7
|
"exports": {
|
|
8
8
|
".": "./src/index.js",
|
|
9
|
+
"./guard": "./src/guard.js",
|
|
9
10
|
"./package.json": "./package.json"
|
|
10
11
|
},
|
|
11
12
|
"dsh": {
|
|
@@ -37,6 +38,7 @@
|
|
|
37
38
|
"@deepseek-ai/dsh-llm-pi-ai": ">=0.1.2-rc.1"
|
|
38
39
|
},
|
|
39
40
|
"devDependencies": {
|
|
41
|
+
"@deepseek-ai/cordis": "4.0.2",
|
|
40
42
|
"@deepseek-ai/schemastery": "3.18.2",
|
|
41
43
|
"@deepseek-ai/dsh-llm": "0.1.2-rc.1",
|
|
42
44
|
"@deepseek-ai/dsh-settings": "0.1.2-rc.1",
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// gateway 主行 apply 生命周期旗标:guard 据此识别"行活着但功能性未接管"
|
|
2
|
+
// (旧宿主缺必备导出,apply 干净早退的假活形态)。经 globalThis 符号键承载,
|
|
3
|
+
// 防同包多实例模块图读不到状态。
|
|
4
|
+
|
|
5
|
+
const KEY = Symbol.for('@mzzsfy/dsh-llm-pi-gateway.applyState')
|
|
6
|
+
|
|
7
|
+
// apply 开始清除旧态:运行中不可判,guard 保守不代挂
|
|
8
|
+
export function beginGatewayApply() {
|
|
9
|
+
delete globalThis[KEY]
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// apply 走到服务注册完成:本行有效接管
|
|
13
|
+
export function endGatewayApplyActive() {
|
|
14
|
+
globalThis[KEY] = 'active'
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// apply 因宿主能力缺失干净早退:行活着但不提供任何服务
|
|
18
|
+
export function endGatewayApplyInactive() {
|
|
19
|
+
globalThis[KEY] = 'inactive'
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** @returns {'active'|'inactive'|undefined} undefined = 未运行/运行中/中途崩溃 */
|
|
23
|
+
export function gatewayApplyState() {
|
|
24
|
+
return globalThis[KEY]
|
|
25
|
+
}
|
package/src/guard.js
ADDED
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
// llm-pi-gateway guard 哨兵行:死态自愈,守服务不抢权。
|
|
2
|
+
// 死态 = gateway 行功能性停摆(行被禁并停稳,或行活着但 apply 因宿主能力
|
|
3
|
+
// 缺失干净早退)且官方行仍被 bundle patch 禁用并停稳——无任何插件服务
|
|
4
|
+
// provider 路由,所有模型不可用。触发路径:dsh-market 对本包(禁用承载
|
|
5
|
+
// 官方行的 disable-carrier)的 UI 开关写入即时生效的 user patch 行禁用,
|
|
6
|
+
// 而其恢复机制(整包移出 bundles)只在下次 boot 生效,窗口内 composed
|
|
7
|
+
// 自相矛盾;旧宿主(rc.2)则恒为假活形态。
|
|
8
|
+
// 自愈手段:动态挂载官方插件模块(ctx.plugin),settings 节与模型注册全
|
|
9
|
+
// 原生语义,挂 guard 子 context 随 guard fiber 卸载。gateway 行或官方行
|
|
10
|
+
// 任一复活时先卸代挂再放行。guard 行 id 含 "/",dsh-market 的行写入对其
|
|
11
|
+
// 拒绝,窗口内始终存活,是死态唯一的自愈执行者。
|
|
12
|
+
//
|
|
13
|
+
// 版本兼容(0.1.1-rc.2 / 0.1.2-rc.1 / 0.1.5-alpha.1 实测):
|
|
14
|
+
// - 行 id 解析:dsh 宿主把 profile 行树经 cordis:include 行挂载,嵌套
|
|
15
|
+
// 分隔符是 ":",受控行的实际解析 id 带前缀(裸 id resolve 抛错)。
|
|
16
|
+
// - guard 的 apply 与兄弟行同处一个 update 事务,首判可能早于兄弟行入树,
|
|
17
|
+
// PENDING 时必须延迟重试,不能当真/假值使用。
|
|
18
|
+
// - 边沿事件时序因宿主版本而异(toggle 后的 in-process 重启、live 重载
|
|
19
|
+
// 事务都会吞掉或推迟边沿),以低频轮询兜底,mount/unmount 均幂等,
|
|
20
|
+
// 事件与轮询并发安全。
|
|
21
|
+
|
|
22
|
+
import {
|
|
23
|
+
OFFICIAL_ENTRY_ID,
|
|
24
|
+
EXIT_POLL_INTERVAL_MS,
|
|
25
|
+
EXIT_POLL_ROUNDS,
|
|
26
|
+
ROW_ID_PREFIXES,
|
|
27
|
+
} from './takeover.mjs'
|
|
28
|
+
import { gatewayApplyState } from './apply-state.mjs'
|
|
29
|
+
|
|
30
|
+
// 本包主行 id(cordis.patch.yml 的 insert 声明)
|
|
31
|
+
const GATEWAY_ENTRY_ID = 'llm-pi-gateway'
|
|
32
|
+
// guard 行 id:带 "/" 使 market 的 ROW_ID_RE 拒绝写入 user patch 层
|
|
33
|
+
export const GUARD_FOR_GATEWAY_ID = 'llm-pi-gateway/guard'
|
|
34
|
+
// 官方插件 settings 命名空间(官方源码常量同构)
|
|
35
|
+
const OFFICIAL_SETTINGS_NS = 'llm-pi-ai'
|
|
36
|
+
|
|
37
|
+
// 轮询兜底周期:边沿事件缺失/丢失时的状态跟随精度,幂等不抖动
|
|
38
|
+
const SWEEP_INTERVAL_MS = 3000
|
|
39
|
+
// boot 树就绪等待:兄弟行 update 事务完成的上限(超时按当前状态判定)
|
|
40
|
+
const BOOT_SETTLE_ROUNDS = 50
|
|
41
|
+
const BOOT_SETTLE_INTERVAL_MS = 100
|
|
42
|
+
|
|
43
|
+
// 无法解析该 id:boot 早期兄弟行未入树,或行确实不存在;两者对"是否
|
|
44
|
+
// 死态"都不可判定,调用方应稍后重试
|
|
45
|
+
const PENDING = null
|
|
46
|
+
|
|
47
|
+
// 解析按 ROW_ID_PREFIXES 候选顺序兜底;全部解析失败报 PENDING
|
|
48
|
+
function resolveEntry(loader, id) {
|
|
49
|
+
for (const prefix of ROW_ID_PREFIXES) {
|
|
50
|
+
try {
|
|
51
|
+
const entry = loader?.resolve(prefix + id)
|
|
52
|
+
if (entry) return entry
|
|
53
|
+
} catch {
|
|
54
|
+
// 该候选空间无此 id,试下一候选
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return PENDING
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// 行有效禁用读宿主 Entry.disabled getter(!!js 求值 + 父链 + 宽化)。
|
|
61
|
+
// 调用方保证 entry 非 undefined/PENDING;求值抛错按未禁用处理(保守向:
|
|
62
|
+
// 少代挂,不多抢)
|
|
63
|
+
function effectiveDisabled(entry) {
|
|
64
|
+
try {
|
|
65
|
+
return entry.disabled === true
|
|
66
|
+
} catch {
|
|
67
|
+
return false
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// 行停稳 = fiber 已撤清(uid null 即 dispose 完成,注册已全部释放)
|
|
72
|
+
function settled(entry) {
|
|
73
|
+
return entry.fiber?.uid == null
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* 死态判定:gateway 行功能性停摆(禁用停稳,或 apply 声明未接管),且官方
|
|
78
|
+
* 行禁用并已停稳。官方行未禁 = 官方自行服务。gateway apply 中途崩溃
|
|
79
|
+
* (旗标 undefined)不在判定内:自愈执行者不越权重启他人。
|
|
80
|
+
* 返回 PENDING 表示行尚无法解析(树未就绪或行不存在),调用方应稍后
|
|
81
|
+
* 重试——PENDING 不能当真值也不能当假值。
|
|
82
|
+
* @param {object|undefined} loader
|
|
83
|
+
* @returns {boolean|PENDING}
|
|
84
|
+
*/
|
|
85
|
+
export function detectDeadState(loader) {
|
|
86
|
+
const gateway = resolveEntry(loader, GATEWAY_ENTRY_ID)
|
|
87
|
+
const official = resolveEntry(loader, OFFICIAL_ENTRY_ID)
|
|
88
|
+
if (gateway === PENDING || official === PENDING) return PENDING
|
|
89
|
+
const gatewayOff = (effectiveDisabled(gateway) && settled(gateway))
|
|
90
|
+
|| gatewayApplyState() === 'inactive'
|
|
91
|
+
if (!gatewayOff) return false
|
|
92
|
+
return effectiveDisabled(official) && settled(official)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// 树就绪判定:两个受控行都可解析(apply 跑在兄弟行 update 事务内时,
|
|
96
|
+
// 解析会失败,此时不能做初始判定)
|
|
97
|
+
function treeReady(loader) {
|
|
98
|
+
return detectDeadState(loader) !== PENDING
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// 等待树就绪:guard apply 与兄弟行同事务,首判前必须等 gateway/official
|
|
102
|
+
// 行完成创建。上限 BOOT_SETTLE_ROUNDS 次,超时按当前状态判定(树长期
|
|
103
|
+
// 不就绪属宿主异常,持续轮询自会在就绪后接管)
|
|
104
|
+
async function waitTreeReady(loader, delay) {
|
|
105
|
+
for (let round = 0; round < BOOT_SETTLE_ROUNDS; round += 1) {
|
|
106
|
+
if (treeReady(loader)) return true
|
|
107
|
+
await delay(BOOT_SETTLE_INTERVAL_MS)
|
|
108
|
+
}
|
|
109
|
+
return treeReady(loader)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// 官方包必须取自运行宿主本体树:guard 所在包经解析链裸 import 官方名会
|
|
113
|
+
// 命中与本宿主版本脱钩的副本(如仓库根 symlink 指向最新版本体),其
|
|
114
|
+
// apply 在旧宿主服务面上静默失能(注册空目录),代挂形同虚设。宿主入口
|
|
115
|
+
// (process.argv[1] = bin.js)所在 node_modules 树与本宿主 llm/settings
|
|
116
|
+
// 服务同源,经 createRequire 定位物理文件再动态 import。定位失败回退
|
|
117
|
+
// 裸 import(单一本体安装的常规形态下两者一致)
|
|
118
|
+
async function importOfficialFromHost() {
|
|
119
|
+
const resolved = await resolveFromHostTree('@deepseek-ai/dsh-llm-pi-ai')
|
|
120
|
+
if (resolved) {
|
|
121
|
+
const { pathToFileURL } = await import('node:url')
|
|
122
|
+
return import(pathToFileURL(resolved).href)
|
|
123
|
+
}
|
|
124
|
+
return import('@deepseek-ai/dsh-llm-pi-ai')
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// 在宿主入口的依赖树内解析包的物理路径;宿主入口不可定位返回 null。
|
|
128
|
+
// realpath 穿透 junction/pnpm 软链,落到宿主本体 .pnpm 实体,解析才能
|
|
129
|
+
// 命中同宿主依赖树的包,而非外层链上的他版本副本
|
|
130
|
+
async function resolveFromHostTree(packageName) {
|
|
131
|
+
try {
|
|
132
|
+
const { createRequire } = await import('node:module')
|
|
133
|
+
const { pathToFileURL } = await import('node:url')
|
|
134
|
+
const { realpath } = await import('node:fs/promises')
|
|
135
|
+
const entry = process.argv[1]
|
|
136
|
+
if (!entry) return null
|
|
137
|
+
const realEntry = await realpath(entry)
|
|
138
|
+
const require = createRequire(pathToFileURL(realEntry))
|
|
139
|
+
return await realpath(require.resolve(packageName))
|
|
140
|
+
} catch {
|
|
141
|
+
return null
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* 安装 guard:树就绪后判定初始死态,此后事件跟随 + 轮询兜底双通道跟随
|
|
147
|
+
* 死态/复活边沿——死态时代挂官方,任一行复活先卸代挂。全部状态挂 guard
|
|
148
|
+
* 自身 fiber,随其卸载自动清理。
|
|
149
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx
|
|
150
|
+
* @param {{importOfficial?: () => Promise<object>, rowConfig?: () => Promise<object>, delay?: (ms: number) => Promise<void>}} hooks
|
|
151
|
+
* 测试注入桩;官方包默认取自宿主本体树,行配置默认读宿主 settings 文件,
|
|
152
|
+
* 缺失即干净禁用
|
|
153
|
+
*/
|
|
154
|
+
export async function installGuard(ctx, {
|
|
155
|
+
importOfficial = importOfficialFromHost,
|
|
156
|
+
rowConfig,
|
|
157
|
+
delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
158
|
+
} = {}) {
|
|
159
|
+
let officialModule
|
|
160
|
+
try {
|
|
161
|
+
officialModule = await importOfficial()
|
|
162
|
+
} catch (error) {
|
|
163
|
+
ctx.logger.warn(`llm-pi-gateway/guard: 官方 dsh-llm-pi-ai 不可用,死态自愈停用: ${error?.message ?? error}`)
|
|
164
|
+
return undefined
|
|
165
|
+
}
|
|
166
|
+
let mounted = null
|
|
167
|
+
let mounting = null
|
|
168
|
+
|
|
169
|
+
// settings 服务可达性经注入 API 探测:guard 未声明 inject,直接读
|
|
170
|
+
// ctx.settings 在注入门控宿主上会抛错拖垮插件树。回调到达即服务就绪,
|
|
171
|
+
// 同时留下服务面供行配置读取
|
|
172
|
+
let settingsCtx = null
|
|
173
|
+
try {
|
|
174
|
+
ctx.inject(['settings'], (sctx) => { settingsCtx = sctx })
|
|
175
|
+
} catch {
|
|
176
|
+
// 注入 API 缺失,行配置退化为空,服务可达性视为未就绪
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// 官方 ns 行内配置:官方 apply 的目录首注册先于其 settings 接线,行
|
|
180
|
+
// 配置为空会让部分宿主版本以空目录注册失败,且失败后再无补齐通道。
|
|
181
|
+
// settings 服务面读不到该节(官方行禁用时无人 register),只能读宿主
|
|
182
|
+
// 落盘文件;接线完成后仍由 settings 面接管(setSource 覆盖行配置)
|
|
183
|
+
async function officialRowConfig() {
|
|
184
|
+
try {
|
|
185
|
+
const settingsFile = await resolveFromHostTree('@deepseek-ai/dsh-home-paths')
|
|
186
|
+
const resolvedYaml = await resolveFromHostTree('yaml')
|
|
187
|
+
if (!resolvedYaml) return {}
|
|
188
|
+
const { readFile } = await import('node:fs/promises')
|
|
189
|
+
const { dirname, join } = await import('node:path')
|
|
190
|
+
const { pathToFileURL } = await import('node:url')
|
|
191
|
+
const { parse } = await import(pathToFileURL(resolvedYaml).href)
|
|
192
|
+
let home = null
|
|
193
|
+
if (settingsFile) {
|
|
194
|
+
try {
|
|
195
|
+
;({ resolveDshHome: home } = await import(pathToFileURL(settingsFile).href))
|
|
196
|
+
} catch {
|
|
197
|
+
home = null
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
const filename = typeof home === 'function' ? join(home(), 'settings.yaml') : join(dirname(process.execPath), 'settings.yaml')
|
|
201
|
+
const document = parse(await readFile(filename, 'utf8'))
|
|
202
|
+
return document?.[OFFICIAL_SETTINGS_NS] ?? {}
|
|
203
|
+
} catch {
|
|
204
|
+
return {}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// 跨版本 compat 键差异:旧宿主的 wire protocol 不声明新键即拒整节,
|
|
209
|
+
// 行配置随之失效。拒绝信息机器可读,记录被拒键并在后续构造时剥离,
|
|
210
|
+
// 收敛后行配置即通过本宿主校验;仅影响代挂首注册,settings 接线后
|
|
211
|
+
// 以 settings 面为准
|
|
212
|
+
const refusedCompatKeys = new Set()
|
|
213
|
+
const REFUSED_COMPAT_RE = /sets compat "([^"]+)", which no wire protocol declares/g
|
|
214
|
+
function recordRefusedCompatKeys(message) {
|
|
215
|
+
for (const match of String(message ?? '').matchAll(REFUSED_COMPAT_RE)) {
|
|
216
|
+
refusedCompatKeys.add(match[1])
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
function stripRefusedCompatKeys(section) {
|
|
220
|
+
if (refusedCompatKeys.size === 0) return section
|
|
221
|
+
const providers = section?.providers
|
|
222
|
+
if (typeof providers !== 'object' || providers === null) return section
|
|
223
|
+
const cleaned = structuredClone(section)
|
|
224
|
+
for (const provider of Object.values(cleaned.providers)) {
|
|
225
|
+
for (const model of provider?.models ?? []) {
|
|
226
|
+
if (typeof model?.compat === 'object' && model.compat !== null) {
|
|
227
|
+
for (const key of refusedCompatKeys) delete model.compat[key]
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return cleaned
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const settingsReady = () => settingsCtx !== null
|
|
235
|
+
|
|
236
|
+
// 代挂官方插件:挂 guard 子 context,Config 校验与 settings 节全原生
|
|
237
|
+
// 语义;同一时刻至多一个代挂(mounting 去并发,mounted 去重入)
|
|
238
|
+
async function mountOfficial() {
|
|
239
|
+
if (mounted !== null || mounting !== null) return
|
|
240
|
+
mounting = (async () => {
|
|
241
|
+
try {
|
|
242
|
+
const section = await (rowConfig?.() ?? officialRowConfig())
|
|
243
|
+
const fiber = ctx.plugin(officialModule, stripRefusedCompatKeys(section))
|
|
244
|
+
await fiber
|
|
245
|
+
mounted = fiber
|
|
246
|
+
ctx.logger.warn('llm-pi-gateway/guard: 检测到 gateway 与官方行同时停用,已代挂官方插件恢复服务;重启后由宿主组合自然归位')
|
|
247
|
+
} catch (error) {
|
|
248
|
+
recordRefusedCompatKeys(error?.message)
|
|
249
|
+
ctx.logger.error(`llm-pi-gateway/guard: 代挂官方插件失败: ${error?.message ?? error}`)
|
|
250
|
+
} finally {
|
|
251
|
+
mounting = null
|
|
252
|
+
}
|
|
253
|
+
})()
|
|
254
|
+
await mounting
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// 卸代挂:行复活的前置交接,必须完成后才放行其 apply。等待在途代挂
|
|
258
|
+
// 完成,防复活放行与代挂激活交错抢注册
|
|
259
|
+
async function unmountOfficial() {
|
|
260
|
+
if (mounting !== null) await mounting
|
|
261
|
+
if (mounted === null) return
|
|
262
|
+
const fiber = mounted
|
|
263
|
+
mounted = null
|
|
264
|
+
try {
|
|
265
|
+
await fiber.dispose()
|
|
266
|
+
} catch (error) {
|
|
267
|
+
ctx.logger.warn(`llm-pi-gateway/guard: 卸代挂失败(放行继续,接管方遇注册冲突会降级): ${error?.message ?? error}`)
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const patchContextGuard = async (entry, next) => {
|
|
272
|
+
const id = entry?.options?.id
|
|
273
|
+
if (id !== GATEWAY_ENTRY_ID && id !== OFFICIAL_ENTRY_ID) return next()
|
|
274
|
+
if (entry.fiber?.uid != null) return next()
|
|
275
|
+
if (mounted === null && mounting === null) return next()
|
|
276
|
+
ctx.logger.warn(`llm-pi-gateway/guard: ${id} 行复活,卸代挂让位`)
|
|
277
|
+
try {
|
|
278
|
+
await unmountOfficial()
|
|
279
|
+
} finally {
|
|
280
|
+
return next()
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// 禁用边沿:行退场事件后,等其注册撤清(fiber 消失)再判死态,防与
|
|
285
|
+
// 退场序列并发抢占官方注册。官方行退场(手动禁官方行)同样可能铸成
|
|
286
|
+
// 死态。fiber 在场的事件是配置更新/重启路径(非退场),快速返回:
|
|
287
|
+
// 宿主全部禁用/移除发射点都在 dispose 完成后
|
|
288
|
+
const partialDisposeGuard = async (entry) => {
|
|
289
|
+
const id = entry?.options?.id
|
|
290
|
+
if (id !== GATEWAY_ENTRY_ID && id !== OFFICIAL_ENTRY_ID) return
|
|
291
|
+
if (entry.fiber?.uid != null) return
|
|
292
|
+
for (let round = 0; round < EXIT_POLL_ROUNDS; round += 1) {
|
|
293
|
+
if (entry.fiber?.uid == null) break
|
|
294
|
+
await delay(EXIT_POLL_INTERVAL_MS)
|
|
295
|
+
}
|
|
296
|
+
if (detectDeadState(ctx.loader) === true && settingsReady()) await mountOfficial()
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// 轮询兜底:边沿事件的时序因宿主版本而异(toggle 后的 in-process 重启、
|
|
300
|
+
// live 重载事务等都会吞掉或推迟边沿),周期性全量扫描保证死态→代挂、
|
|
301
|
+
// 复活→卸代挂的最终一致。与事件通道幂等并发:mount/unmount 各自去重,
|
|
302
|
+
// 双通道同时触发不会叠加。PENDING 轮跳过,下轮再扫。mount 前置条件:
|
|
303
|
+
// settings 服务已 provide——官方目录首注册需要 settings 现值作行配置,
|
|
304
|
+
// 且 boot 事务内 mount 会让官方插件的 settings 接线时序劣化
|
|
305
|
+
let sweeping = false
|
|
306
|
+
const sweep = async () => {
|
|
307
|
+
if (sweeping) return
|
|
308
|
+
sweeping = true
|
|
309
|
+
try {
|
|
310
|
+
const dead = detectDeadState(ctx.loader)
|
|
311
|
+
if (dead === true && settingsReady() && mounted === null && mounting === null) await mountOfficial()
|
|
312
|
+
else if (dead === false && (mounted !== null || mounting !== null)) await unmountOfficial()
|
|
313
|
+
} finally {
|
|
314
|
+
sweeping = false
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
ctx.on('loader/patch-context', patchContextGuard, { global: true })
|
|
319
|
+
ctx.on('loader/partial-dispose', partialDisposeGuard, { global: true })
|
|
320
|
+
|
|
321
|
+
// 初始判定前必须等树就绪:apply 与兄弟行同事务,首判早了会全部
|
|
322
|
+
// PENDING 而空转。settings 服务已注入即就地自愈;否则交由事件通道与
|
|
323
|
+
// sweep(同一门槛)兜底,官方目录首注册需要其现值
|
|
324
|
+
await waitTreeReady(ctx.loader, delay)
|
|
325
|
+
if (detectDeadState(ctx.loader) === true && settingsReady()) await mountOfficial()
|
|
326
|
+
|
|
327
|
+
// 兜底轮询:guard 行 fiber 存续期间持续扫描。guard entry 被宿主移除
|
|
328
|
+
// (随 bundle patch 退场)即整体退场;fiber dispose 后 sweep 空转无害
|
|
329
|
+
// (mounted 状态已无从变更)。unref 防定时器独占进程存活权;不能
|
|
330
|
+
// await ctx.fiber:apply 内 await 自身 fiber 会死锁
|
|
331
|
+
const timer = setInterval(() => { void sweep() }, SWEEP_INTERVAL_MS)
|
|
332
|
+
timer.unref?.()
|
|
333
|
+
return undefined
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
export const name = 'llm-pi-gateway/guard'
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* guard 行入口:零配置,无服务依赖(loader 经 ctx 原型链可达)。
|
|
340
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx
|
|
341
|
+
*/
|
|
342
|
+
export async function apply(ctx) {
|
|
343
|
+
await installGuard(ctx)
|
|
344
|
+
}
|