@mzzsfy/dsh-llm-pi-gateway 0.3.0 → 0.3.1

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 CHANGED
@@ -36,7 +36,7 @@ format 未声明时由 pi-ai 自动检测:provider 名为 `openrouter`(精确匹
36
36
  - 接管的路由自动获得 sessionId 注入(请求体标记默认开启;亲和头 anthropic-messages / openai-completions 经 compat 开启,openai-responses 默认开启),其余行为与官方逐项对表(见下节)
37
37
  - `llm-pi-gateway:` 节仍可用:独立声明路由,或与官方节同名时整体覆盖(增强)
38
38
  - **卸载即还原**:patch 随 bundle 移除,官方插件恢复,同一份配置继续由官方服务
39
- - 防御:若 patch 失效(宿主升级等)官方插件仍在,本包接管官方节失败时降级为只服务 `llm-pi-gateway:` 节并记日志(命名空间注册冲突与一般失败区分文案),不阻塞启动;官方包本体不可用时同样降级并告警
39
+ - 防御:若 patch 失效(宿主升级等)官方插件仍在,本包接管官方节失败时降级为只服务 `llm-pi-gateway:` 节并记日志(命名空间注册冲突与一般失败区分文案),不阻塞启动;官方包本体不可用时同样降级并告警;运行中安装/热重载时官方行退场被在途流拖长属于常态,快速等待窗耗尽只表示接管推迟——官方行退场后本包自动补完成接管,不永久降级
40
40
 
41
41
  ### 禁用与还原
42
42
 
@@ -53,7 +53,7 @@ format 未声明时由 pi-ai 自动检测:provider 名为 `openrouter`(精确匹
53
53
  disabled: false
54
54
  ```
55
55
 
56
- - 官方行复活让位守卫:上述两行写入后热重载时,本包在官方插件重新注册前自停让位,官方无缝接管,不出现注册冲突拖垮 patch 应用;反之删掉这两行恢复接管时,本包等官方插件退场落定后再接管
56
+ - 官方行复活让位守卫:上述两行写入后热重载时,本包在官方插件重新注册前自停让位,官方无缝接管,不出现注册冲突拖垮 patch 应用;反之删掉这两行恢复接管时,本包等官方插件退场落定后再接管——退场受在途流拖长而超出快速等待窗(约 1 秒)时,本包先只服务 `llm-pi-gateway:` 节(告警披露),官方行最终退场后**自动补完成接管**(复活守卫 + 官方 discovery/节 + 路由重算),服务与 sessionId 注入最终一致,无需重启
57
57
  - 完全卸载本包(`dsh plugin remove`)无需任何 patch 改动,patch 随 bundle 移除,官方行自动恢复
58
58
  - 两节 provider 同名重叠时的归属:官方在场(让位态)下官方插件先注册,本包自有节若声明了与官方节同名的 provider,整组路由注册被宿主排他检查拒绝,本包节路由(含不重叠的)整体不服务,仅日志披露——让位态请避免两节同名,或接受官方节优先
59
59
  - 官方行 `disabled` 写为 `!!js` 表达式且求值出错时,本包按未禁用让位(官方节归官方,不制造注册冲突);若官方行实际未启用,会出现双空闲(provider 路由无人服务),需修正表达式或改静态布尔
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mzzsfy/dsh-llm-pi-gateway",
3
3
  "description": "NewAPI 等 LLM 网关接入:向每个上游请求注入会话 sessionId(请求体派生标记 + 亲和头裸 sessionId),供网关做请求亲和性粘性路由,compat 全开放",
4
- "version": "0.3.0",
4
+ "version": "0.3.1",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
7
7
  "exports": {
package/src/index.js CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  takeoverDecision,
15
15
  awaitOfficialExit,
16
16
  installOfficialRevivalGuard,
17
+ armDeferredTakeover,
17
18
  } from './takeover.mjs'
18
19
  import { beginGatewayApply, endGatewayApplyActive, endGatewayApplyInactive } from './apply-state.mjs'
19
20
 
@@ -82,8 +83,11 @@ export function missingHostExports(dshLlm) {
82
83
  * @param {() => Promise<object>} [importOfficial] 官方包加载器,测试注入桩;
83
84
  * 默认动态 import(官方包缺失时仅降级本节接管,不拖垮本包加载——
84
85
  * 静态 import 命名导出缺失即加载崩溃,违反干净禁用规约)
86
+ * @param {{exitPoll?: object, deferredExit?: object}} [pollOptions] 轮询参数,
87
+ * 仅测试注入:exitPoll 透传快速退场窗(awaitOfficialExit),deferredExit
88
+ * 透传延迟补接管轮询(armDeferredTakeover);生产双双缺省
85
89
  */
86
- export async function apply(ctx, config, importOfficial = () => import('@deepseek-ai/dsh-llm-pi-ai')) {
90
+ export async function apply(ctx, config, importOfficial = () => import('@deepseek-ai/dsh-llm-pi-ai'), { exitPoll = {}, deferredExit = {} } = {}) {
87
91
  // 生命周期旗标:guard 据此识别本行的功能性停摆(早退 = 假活,详见
88
92
  // apply-state.mjs);中途崩溃旗标停留 undefined,guard 保守不代挂
89
93
  beginGatewayApply()
@@ -120,13 +124,20 @@ export async function apply(ctx, config, importOfficial = () => import('@deepsee
120
124
  const officialState = officialEntryState(ctx.loader)
121
125
  const decision = takeoverDecision(officialState)
122
126
  let takeover
127
+ // 退场超时标记:arm 延迟到全部装配落定后执行,装配中途失败不留僵尸轮询
128
+ let exitTimedOut = false
123
129
  if (decision === 'yield') {
124
130
  ctx.logger.warn('llm-pi-gateway: 官方 llm-pi-ai 行未被禁用(用户层启用),本包降级为只服务 llm-pi-gateway 节')
125
131
  takeover = false
126
132
  } else if (decision === 'await-exit') {
127
133
  ctx.logger.warn('llm-pi-gateway: 官方 llm-pi-ai 插件退场中,等待其完全卸载后接管')
128
- takeover = await awaitOfficialExit(officialState.entry)
129
- if (!takeover) ctx.logger.error('llm-pi-gateway: 官方 llm-pi-ai 插件退场超时,降级为只服务 llm-pi-gateway 节')
134
+ takeover = await awaitOfficialExit(officialState.entry, exitPoll)
135
+ if (!takeover) {
136
+ // 退场受在途流拖长属常态(用户边用边装),快速窗耗尽只是接管推迟:
137
+ // 先服务本包节,官方退场后补完成接管,服务与标记注入最终一致
138
+ ctx.logger.warn('llm-pi-gateway: 官方 llm-pi-ai 退场超时(存在在途流),先服务本包节,官方退场后自动完成接管')
139
+ exitTimedOut = true
140
+ }
130
141
  } else {
131
142
  takeover = true
132
143
  }
@@ -184,8 +195,9 @@ export async function apply(ctx, config, importOfficial = () => import('@deepsee
184
195
  // 官方 ns 补注册仅在接管态:让位态官方插件在场,会自行注册该 ns,本包
185
196
  // 抢注册必令官方 init 撞 DUPLICATE_DISCOVERY 而拖垮整批 patch 应用。
186
197
  // 注册成功即置 servingOfficial:持有任一官方注册就需要复活守卫,官方
187
- // 复活撞本包在场 discovery 与撞 settings 节的后果相同
188
- if (takeover) {
198
+ // 复活撞本包在场 discovery 与撞 settings 节的后果相同。
199
+ // 同步接管与延迟补接管共用同一装配,延迟路径在退场后的轮询序列上调用
200
+ const registerOfficialDiscovery = () => {
189
201
  try {
190
202
  ctx.llm.registerModelDiscovery(OFFICIAL_NS, discoverFor)
191
203
  servingOfficial = true
@@ -194,6 +206,7 @@ export async function apply(ctx, config, importOfficial = () => import('@deepsee
194
206
  ctx.logger.warn(error)
195
207
  }
196
208
  }
209
+ if (takeover) registerOfficialDiscovery()
197
210
  const onSectionChange = () => {
198
211
  try {
199
212
  manager.ensureRegistration()
@@ -212,8 +225,10 @@ export async function apply(ctx, config, importOfficial = () => import('@deepsee
212
225
  // 本包以官方 schema 注册。若注册冲突(patch 失效、官方仍在),降级为只
213
226
  // 服务本包节。validate 拒绝组合后不可解析的官方节,防坏配置穿透 profiles
214
227
  // 快照记忆。官方包缺失时跳过接管(动态获取已告警)。servingOfficial 已在
215
- // 官方 discovery 注册处置位,此处不再改写
216
- if (OfficialConfig !== undefined && takeover) {
228
+ // 官方 discovery 注册处置位,此处不再改写。
229
+ // 同步接管与延迟补接管共用同一装配
230
+ const installOfficialSection = () => {
231
+ if (OfficialConfig === undefined) return
217
232
  try {
218
233
  ctx.settings.installSection(ctx, OFFICIAL_NS, OfficialConfig, undefined, {
219
234
  validate: (section) => resolveRoutes(section.providers, readGateway()?.providers, onUnserviceable),
@@ -227,6 +242,15 @@ export async function apply(ctx, config, importOfficial = () => import('@deepsee
227
242
  ctx.logger.error(error)
228
243
  }
229
244
  }
245
+ // 延迟补接管装配:与同步接管同一套动作、同一顺序(守卫→discovery→节→
246
+ // 路由重算),在官方行退场后的轮询序列上执行
247
+ const completeOfficialTakeover = () => {
248
+ installOfficialRevivalGuard(ctx, () => servingOfficial)
249
+ registerOfficialDiscovery()
250
+ installOfficialSection()
251
+ onSectionChange()
252
+ }
253
+ if (takeover) installOfficialSection()
230
254
  ctx.settings.installSection(ctx, NS, Config, config, {
231
255
  validate: (section) => resolveRoutes(readOfficial()?.providers, section.providers, onUnserviceable),
232
256
  setSource: (source) => {
@@ -238,4 +262,7 @@ export async function apply(ctx, config, importOfficial = () => import('@deepsee
238
262
  profiles()
239
263
  onSectionChange()
240
264
  endGatewayApplyActive()
265
+ // 延迟补接管:全部装配落定后才武装;轮询在 apply 返回后的轮询序列上执行,
266
+ // completeOfficialTakeover 闭包至此全部就绪,装配中途失败不会留下僵尸轮询
267
+ if (exitTimedOut) armDeferredTakeover(ctx, officialState.entry, () => completeOfficialTakeover(), deferredExit)
241
268
  }
package/src/takeover.mjs CHANGED
@@ -14,6 +14,9 @@ export const OFFICIAL_ENTRY_ID = 'llm-pi-ai'
14
14
  export const EXIT_POLL_INTERVAL_MS = 20
15
15
  export const EXIT_POLL_ROUNDS = 50
16
16
 
17
+ // 延迟补接管轮询粒度:dispose 拖长(在途流)场景下,退场完成后的接管时延上界
18
+ export const DEFERRED_EXIT_POLL_INTERVAL_MS = 500
19
+
17
20
  // 行 id 解析候选前缀:宿主把 profile 行树经 cordis:include 行挂载(嵌套
18
21
  // 分隔符 EntryTree.sep = ":"),受控行的实际解析 id 带前缀;空串覆盖裸树
19
22
  // 形态。resolve 对缺失 id 抛错,调用方逐候选试解
@@ -74,6 +77,12 @@ export function takeoverDecision(state) {
74
77
 
75
78
  const defaultDelay = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
76
79
 
80
+ // unref 延迟:延迟补接管为常驻轮询,不能凭 pending timer 阻止进程退出
81
+ const unrefDelay = (ms) => new Promise((resolve) => {
82
+ const timer = setTimeout(resolve, ms)
83
+ timer.unref?.()
84
+ })
85
+
77
86
  /**
78
87
  * 有界等待官方插件退场(fiber 消失)。dispose 为纯内存操作毫秒级完成,
79
88
  * 轮询上界仅防御异常宿主状态。fiber 清空后官方注册的撤销还在卸载收尾
@@ -95,6 +104,42 @@ export async function awaitOfficialExit(entry, {
95
104
  return true
96
105
  }
97
106
 
107
+ /**
108
+ * 延迟补接管:await-exit 快速窗耗尽只是决策推迟——官方行 dispose 受在途流拖长
109
+ * 时,退场晚于快速窗,若就此让位则永远无人补接管。本函数持续轮询官方行,
110
+ * 退场完成后执行 complete(复活守卫 + 官方 discovery/节 + 路由重算),把接管
111
+ * 从一次性决策补成最终一致。gateway 行先退场(被删/禁)则静默放弃,不在死
112
+ * context 上注册;退场完成后补一拍间隔,与快速路径的收尾落定语义一致。
113
+ * @param {import('@deepseek-ai/cordis').Context} ctx 本包 apply context
114
+ * @param {object} entry 官方 loader entry
115
+ * @param {() => void} complete 接管完成动作,在退场后的当前轮询序列上同步执行
116
+ * @param {{intervalMs?: number, delay?: (ms: number) => Promise<void>}} [options]
117
+ * @returns {() => void} 取消轮询(测试用)
118
+ * 轮询持有 entry 引用,依赖宿主 dispose 原地清 fiber 的既有契约(与
119
+ * awaitOfficialExit 同源);行删重建形态超出该契约,由下次重载重新 apply 兜底
120
+ */
121
+ export function armDeferredTakeover(ctx, entry, complete, {
122
+ intervalMs = DEFERRED_EXIT_POLL_INTERVAL_MS,
123
+ delay = unrefDelay,
124
+ } = {}) {
125
+ let cancelled = false
126
+ const poll = async () => {
127
+ while (!cancelled && entry.fiber?.uid != null && ctx.fiber?.uid != null) {
128
+ await delay(intervalMs)
129
+ }
130
+ if (cancelled || ctx.fiber?.uid == null) return
131
+ await delay(intervalMs)
132
+ if (cancelled || ctx.fiber?.uid == null) return
133
+ complete()
134
+ }
135
+ void poll().catch((error) => {
136
+ if (!cancelled) ctx.logger?.warn?.(`llm-pi-gateway: 延迟接管失败: ${error?.message ?? error}`)
137
+ })
138
+ return () => {
139
+ cancelled = true
140
+ }
141
+ }
142
+
98
143
  /**
99
144
  * 安装官方复活守卫:官方行即将 init(无 fiber 的 patch-context 事件)且本包
100
145
  * 正服务官方节时,await 自停完全卸载本包全部注册后放行,官方 apply 无冲突。
@@ -11,6 +11,7 @@ import {
11
11
  takeoverDecision,
12
12
  awaitOfficialExit,
13
13
  installOfficialRevivalGuard,
14
+ armDeferredTakeover,
14
15
  } from '../src/takeover.mjs'
15
16
 
16
17
  const ABSENT_LOADER = undefined
@@ -91,6 +92,78 @@ test('等待退场: 轮询耗尽仍在场返回 false', async () => {
91
92
  assert.equal(polled, false)
92
93
  })
93
94
 
95
+ // 延迟补接管:await-exit 快速窗超时只是"决策推迟",官方行退场完成后必须补完
96
+ // 接管(复活守卫 + 官方 discovery/节),把接管从一次性决策补成最终一致。
97
+
98
+ // 真实定时器微延迟:供轮询循环退场后的收尾等待;空转延迟(noop)的循环必须
99
+ // 靠 cancel/清 fiber 退出,任何断言先失败都会挂死测试进程,收尾一律 try/finally
100
+ const tickDelay = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
101
+
102
+ test('延迟接管: 官方退场后执行 complete 恰好一次', async () => {
103
+ const entry = entryOf({ running: true })
104
+ const logs = { warn: [] }
105
+ const ctx = { logger: { warn: (m) => logs.warn.push(m) }, fiber: { uid: 1 } }
106
+ let completed = 0
107
+ let delayCalls = 0
108
+ const cancel = armDeferredTakeover(ctx, entry, () => { completed += 1 }, {
109
+ intervalMs: 1,
110
+ delay: async () => { delayCalls += 1 },
111
+ })
112
+ try {
113
+ assert.equal(completed, 0, '官方在场时不得提前接管')
114
+ entry.fiber = undefined
115
+ await tickDelay(10)
116
+ assert.equal(completed, 1, '退场完成后必须补接管')
117
+ assert.ok(delayCalls >= 2, '退场后必须补一拍收尾延迟再接管')
118
+ } finally {
119
+ cancel()
120
+ }
121
+ })
122
+
123
+ test('延迟接管: gateway 先退场则放弃,complete 不执行', async () => {
124
+ const entry = entryOf({ running: true })
125
+ const ctx = { logger: { warn: () => {} }, fiber: { uid: 1 } }
126
+ let completed = 0
127
+ const cancel = armDeferredTakeover(ctx, entry, () => { completed += 1 }, { intervalMs: 1, delay: async () => {} })
128
+ try {
129
+ ctx.fiber = undefined
130
+ entry.fiber = undefined
131
+ await tickDelay(10)
132
+ assert.equal(completed, 0, 'gateway 行已退场,补接管必须在死 context 前放弃')
133
+ } finally {
134
+ cancel()
135
+ }
136
+ })
137
+
138
+ test('延迟接管: cancel 句柄生效,complete 不再执行', async () => {
139
+ const entry = entryOf({ running: true })
140
+ const ctx = { logger: { warn: () => {} }, fiber: { uid: 1 } }
141
+ let completed = 0
142
+ const cancel = armDeferredTakeover(ctx, entry, () => { completed += 1 }, { intervalMs: 1, delay: async () => {} })
143
+ try {
144
+ cancel()
145
+ entry.fiber = undefined
146
+ await tickDelay(10)
147
+ assert.equal(completed, 0)
148
+ } finally {
149
+ cancel()
150
+ }
151
+ })
152
+
153
+ test('延迟接管: complete 抛错只告警,不产生未处理拒绝', async () => {
154
+ const entry = entryOf({ running: true })
155
+ const logs = { warn: [] }
156
+ const ctx = { logger: { warn: (m) => logs.warn.push(m) }, fiber: { uid: 1 } }
157
+ const cancel = armDeferredTakeover(ctx, entry, () => { throw new Error('boom') }, { intervalMs: 1, delay: async () => {} })
158
+ try {
159
+ entry.fiber = undefined
160
+ await tickDelay(10)
161
+ assert.match(logs.warn.join('\n'), /延迟接管失败/)
162
+ } finally {
163
+ cancel()
164
+ }
165
+ })
166
+
94
167
  test('守卫: 以 global 监听注册 loader/patch-context', () => {
95
168
  const registered = []
96
169
  const ctx = { on: (name, callback, options) => registered.push({ name, callback, options }) }
@@ -174,7 +247,8 @@ function integrationCtx({ officialEntry, officialDiscoveryPresent = false } = {}
174
247
  get: () => undefined,
175
248
  loader: officialEntry === undefined ? undefined : loaderOf(officialEntry),
176
249
  on: (name, callback, options) => guards.push({ name, callback, options }),
177
- fiber: { dispose: async () => { disposed.value = true } },
250
+ // 真实 cordis 契约:活跃 fiber uid,dispose null(延迟补接管以此为退场判据)
251
+ fiber: { uid: 7, dispose: async () => { disposed.value = true } },
178
252
  llm: {
179
253
  registerAdapter: () => ({ replace: () => {} }),
180
254
  registerConfigurableProviders: () => ({ replace: () => {} }),
@@ -229,12 +303,19 @@ test('接线: 官方行禁用但仍在退场 → 等待退场后接管', async (
229
303
  assert.deepEqual(installed, [OFFICIAL_SETTINGS_NS, SETTINGS_NS], '退场完成后必须接管官方节')
230
304
  })
231
305
 
232
- test('接线: 官方行禁用但退场超时 → 降级,不安装官方节', async () => {
233
- const { ctx, logs, installed, discovery } = integrationCtx({ officialEntry: entryOf({ disabled: true, running: true }) })
234
- await apply(ctx, undefined, OFFICIAL_STUB)
235
- assert.match(logs.error.join('\n'), /退场超时/)
236
- assert.deepEqual(installed, [SETTINGS_NS])
237
- assert.deepEqual(discovery, [SETTINGS_NS])
306
+ test('接线: 官方行禁用但退场超时 → 降级为只服务本包节,武装延迟补接管', async () => {
307
+ const entry = entryOf({ disabled: true, running: true })
308
+ const { ctx, logs, installed, discovery } = integrationCtx({ officialEntry: entry })
309
+ try {
310
+ await apply(ctx, undefined, OFFICIAL_STUB, { exitPoll: { intervalMs: 1, rounds: 1 }, deferredExit: { intervalMs: 1, delay: async () => {} } })
311
+ assert.match(logs.warn.join('\n'), /退场超时/)
312
+ assert.deepEqual(installed, [SETTINGS_NS])
313
+ assert.deepEqual(discovery, [SETTINGS_NS])
314
+ } finally {
315
+ // 释放轮询循环,断言先失败也不得挂死测试进程
316
+ entry.fiber = undefined
317
+ await tickDelay(5)
318
+ }
238
319
  })
239
320
 
240
321
  test('接线: loader 缺失(官方行缺席)→ 接管态,行为与官方包缺失路径一致', async () => {
@@ -284,3 +365,44 @@ test('接线: 官方包缺失 + loader 缺失(双缺席)守卫在位,官方补
284
365
  assert.equal(disposed.value, true, '官方 ns 补注册在本包手中,复活守卫必须生效')
285
366
  assert.equal(released, true)
286
367
  })
368
+
369
+ test('接线: 退场超时先降级服务本包节,官方退场后自动补接管(守卫+discovery+节+路由重算)', async () => {
370
+ const entry = entryOf({ disabled: true, running: true })
371
+ const { ctx, logs, installed, discovery, guards, hooksByNs } = integrationCtx({ officialEntry: entry })
372
+ const pollOptions = { exitPoll: { intervalMs: 1, rounds: 1 }, deferredExit: { intervalMs: 1, delay: async () => {} } }
373
+ try {
374
+ await apply(ctx, undefined, OFFICIAL_STUB, pollOptions)
375
+ assert.match(logs.warn.join('\n'), /退场超时/)
376
+ assert.deepEqual(installed, [SETTINGS_NS], '超时窗口只服务本包节')
377
+ assert.deepEqual(discovery, [SETTINGS_NS])
378
+ assert.equal(guards.length, 0, '补接管前未持有官方注册,不装复活守卫')
379
+ // 官方行退场完成
380
+ entry.fiber = undefined
381
+ await tickDelay(30)
382
+ assert.deepEqual(installed, [SETTINGS_NS, OFFICIAL_SETTINGS_NS], '退场后必须补装官方节')
383
+ assert.deepEqual(discovery, [SETTINGS_NS, OFFICIAL_SETTINGS_NS], '退场后必须补注册官方 discovery')
384
+ assert.equal(guards.length, 1, '补接管完成即持有官方注册,复活守卫必须在位')
385
+ assert.equal(typeof hooksByNs[OFFICIAL_SETTINGS_NS].onChange, 'function')
386
+ } finally {
387
+ entry.fiber = undefined
388
+ await tickDelay(5)
389
+ }
390
+ })
391
+
392
+ test('接线: 退场超时后 gateway 先退场,官方退场不补接管(死 context 放弃)', async () => {
393
+ const entry = entryOf({ disabled: true, running: true })
394
+ const { ctx, logs, installed, discovery } = integrationCtx({ officialEntry: entry })
395
+ const pollOptions = { exitPoll: { intervalMs: 1, rounds: 1 }, deferredExit: { intervalMs: 1, delay: async () => {} } }
396
+ try {
397
+ await apply(ctx, undefined, OFFICIAL_STUB, pollOptions)
398
+ assert.match(logs.warn.join('\n'), /退场超时/)
399
+ ctx.fiber = { uid: null, dispose: async () => {} }
400
+ entry.fiber = undefined
401
+ await tickDelay(30)
402
+ assert.deepEqual(installed, [SETTINGS_NS], 'gateway 已退场,不得在死 context 上补装官方节')
403
+ assert.deepEqual(discovery, [SETTINGS_NS])
404
+ } finally {
405
+ entry.fiber = undefined
406
+ await tickDelay(5)
407
+ }
408
+ })