dsh-session-guard 0.1.2 → 0.1.5-beta.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/CHANGELOG.ja.md +52 -29
- package/CHANGELOG.ko.md +52 -29
- package/CHANGELOG.md +94 -29
- package/INSTALL.ja.md +83 -42
- package/INSTALL.ko.md +83 -42
- package/INSTALL.md +86 -44
- package/INSTALL.zh.md +82 -42
- package/README.en.md +241 -170
- package/README.ja.md +149 -106
- package/README.ko.md +149 -106
- package/README.md +86 -9
- package/dsh.plugin.json +13 -0
- package/lib/client.js +462 -270
- package/lib/client.js.map +1 -1
- package/package.json +101 -78
- package/src/client/badge-text.ts +44 -0
- package/src/client/locales.ts +163 -95
- package/src/client/settings-card.tsx +370 -191
- package/src/client/status-badge.tsx +55 -66
- package/src/deferrals.js +197 -0
- package/src/index.js +303 -293
- package/src/pause-gate.js +451 -416
- package/src/provider-directory.js +101 -0
- package/src/provider.js +139 -0
- package/src/request-guard.js +191 -0
- package/src/retry.js +227 -202
- package/src/settings.js +114 -97
- package/src/targets.js +70 -0
- package/src/time.js +195 -104
- package/src/tool-call-id.js +44 -0
- package/src/wiring.js +263 -0
|
@@ -1,66 +1,55 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* dsh-session-guard — 状态徽标(纯展示,fail-open)。
|
|
3
|
-
*
|
|
4
|
-
* 轮询 host 的 /session-guard/status(全局当前阶段),显示
|
|
5
|
-
*
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
const cls = status.phase === 'peak' ? 'sg-peak' : status.phase === 'weekend' ? 'sg-weekend' : 'sg-off'
|
|
57
|
-
return (
|
|
58
|
-
<span
|
|
59
|
-
className={`sg-status ${cls}`}
|
|
60
|
-
title={`${LABELS[status.phase]} · ${status.timezone}${status.weekendMode ? ' · 周末模式' : ''}`}
|
|
61
|
-
data-sg-phase={status.phase}
|
|
62
|
-
>
|
|
63
|
-
{LABELS[status.phase]}
|
|
64
|
-
</span>
|
|
65
|
-
)
|
|
66
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* dsh-session-guard — 状态徽标(纯展示,fail-open)。
|
|
3
|
+
*
|
|
4
|
+
* 轮询 host 的 /session-guard/status(全局当前阶段),显示 高峰/谷时/周末;
|
|
5
|
+
* 高峰期按二维判定区分「只拦官方」与「全部暂停」(文案见 badge-text.ts)。
|
|
6
|
+
* 仅展示,不做任何队列/会话动作;冻结按钮由 input-traffic 经桥接管(D6/D8)。
|
|
7
|
+
*/
|
|
8
|
+
import { useEffect, useState } from 'react'
|
|
9
|
+
import { badgeTitle, peakLabel, type Status } from './badge-text'
|
|
10
|
+
|
|
11
|
+
/** slot 运行时注入的会话级 props(sessionId 由 dsh 的 SessionStandardProps 提供)。 */
|
|
12
|
+
export interface StatusBadgeProps {
|
|
13
|
+
sessionId?: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const POLL_MS = 15_000
|
|
17
|
+
|
|
18
|
+
/** 状态徽标:轮询全局阶段,显示 高峰/谷时/周末(enabled 关闭或请求失败时静默隐藏)。 */
|
|
19
|
+
export function StatusBadge({ sessionId }: StatusBadgeProps) {
|
|
20
|
+
const [status, setStatus] = useState<Status | null>(null)
|
|
21
|
+
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
if (!sessionId) return
|
|
24
|
+
let cancelled = false
|
|
25
|
+
const poll = async () => {
|
|
26
|
+
try {
|
|
27
|
+
const res = await fetch('/session-guard/status')
|
|
28
|
+
const body = await res.json().catch(() => null)
|
|
29
|
+
if (!cancelled && body?.ok && body.status) setStatus(body.status)
|
|
30
|
+
} catch {
|
|
31
|
+
// fail-open:路由/网络不可达 → 静默,徽标隐藏。
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
void poll()
|
|
35
|
+
const timer = setInterval(poll, POLL_MS)
|
|
36
|
+
return () => {
|
|
37
|
+
cancelled = true
|
|
38
|
+
clearInterval(timer)
|
|
39
|
+
}
|
|
40
|
+
}, [sessionId])
|
|
41
|
+
|
|
42
|
+
if (!status || !status.enabled) return null
|
|
43
|
+
|
|
44
|
+
const cls = status.phase === 'peak' ? 'sg-peak' : status.phase === 'weekend' ? 'sg-weekend' : 'sg-off'
|
|
45
|
+
return (
|
|
46
|
+
<span
|
|
47
|
+
className={`sg-status ${cls}`}
|
|
48
|
+
title={badgeTitle(status)}
|
|
49
|
+
data-sg-phase={status.phase}
|
|
50
|
+
data-sg-provider-guard={status.providerGuard === true ? 'on' : 'off'}
|
|
51
|
+
>
|
|
52
|
+
{peakLabel(status)}
|
|
53
|
+
</span>
|
|
54
|
+
)
|
|
55
|
+
}
|
package/src/deferrals.js
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-session-guard — 延后登记表(host,进程内)。
|
|
3
|
+
*
|
|
4
|
+
* 两类记录:
|
|
5
|
+
* - **挂起(hold)**:请求被挂在 `agent/request` 上,promise 待退峰 resolve。
|
|
6
|
+
* 退峰 → `resolve(config)` 让请求照常发出;abort / 超限 / 被新 hold 顶替 → reject。
|
|
7
|
+
* - **延后(deferred)**:error 模式下已经抛错的会话,记下来供退峰 `followup` 续跑。
|
|
8
|
+
*
|
|
9
|
+
* 纪律:
|
|
10
|
+
* - **不做磁盘持久化**(挂起态是进程内 promise,重启即消失;写盘无收益)。
|
|
11
|
+
* - 同一会话重复 hold → 先结算旧记录(reject `superseded`),再登记新记录。
|
|
12
|
+
* - `releaseAll` / `rejectAll` / `settle` 幂等:对不存在的会话是 no-op。
|
|
13
|
+
* - 定时器 `unref()`,不拖住进程退出。
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** 机器可路由的延后失败码(`retry.js` 只按这个精确码短路)。 */
|
|
17
|
+
export const PEAK_DEFERRED_CODE = 'PEAK_DEFERRED'
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* 延后错误:`code` 走结构化字段;`message` 以精确哨兵 `PEAK_DEFERRED:` 开头。
|
|
21
|
+
*
|
|
22
|
+
* 为什么要带哨兵:DSH 回合循环只对 `error instanceof LlmError` 保留结构化
|
|
23
|
+
* `failure`,其它一律压成 `{ message: errorChain(error), code: 'UNKNOWN' }`
|
|
24
|
+
* (findings 8.3)。本插件不能 value-import `@deepseek-ai/dsh-llm`,
|
|
25
|
+
* 因此让 `code` 与 message 前缀同时携带同一个精确哨兵,两条路径都能识别。
|
|
26
|
+
*/
|
|
27
|
+
export class PeakDeferredError extends Error {
|
|
28
|
+
/**
|
|
29
|
+
* @param {string} [detail] 人类可读补充说明
|
|
30
|
+
* @param {object} [facts] { provider, model, sessionId }
|
|
31
|
+
*/
|
|
32
|
+
constructor(detail = 'peak hours, request deferred', facts = {}) {
|
|
33
|
+
super(`${PEAK_DEFERRED_CODE}: ${detail}`)
|
|
34
|
+
this.name = 'PeakDeferredError'
|
|
35
|
+
this.code = PEAK_DEFERRED_CODE
|
|
36
|
+
this.facts = facts
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @param {object} [deps]
|
|
42
|
+
* @param {number|(()=>number)} [deps.maxHoldMs] 挂起上限(毫秒),到期转 error;<=0 表示不设上限。
|
|
43
|
+
* 传函数时每次 hold 实时读取(设置热改即时生效)。
|
|
44
|
+
* @param {()=>number} [deps.now] 时钟(测试注入)
|
|
45
|
+
* @param {boolean} [deps.unrefTimers] 定时器是否 unref(生产 true,测试可关以便等待定时器)
|
|
46
|
+
* @returns {object} 延后登记表
|
|
47
|
+
*/
|
|
48
|
+
export function createDeferrals({ maxHoldMs = 6 * 60 * 60 * 1000, now = () => Date.now(), unrefTimers = true } = {}) {
|
|
49
|
+
const resolveMaxHold = typeof maxHoldMs === 'function' ? maxHoldMs : () => maxHoldMs
|
|
50
|
+
/** @type {Map<string, object>} 活跃挂起 */
|
|
51
|
+
const holds = new Map()
|
|
52
|
+
/** @type {Map<string, object>} error 模式的延后记录 */
|
|
53
|
+
const deferred = new Map()
|
|
54
|
+
|
|
55
|
+
function clearTimer(entry) {
|
|
56
|
+
if (entry && entry.timer !== null && entry.timer !== undefined) {
|
|
57
|
+
clearTimeout(entry.timer)
|
|
58
|
+
entry.timer = null
|
|
59
|
+
}
|
|
60
|
+
if (entry && entry.signal && entry.onAbort) {
|
|
61
|
+
try {
|
|
62
|
+
entry.signal.removeEventListener('abort', entry.onAbort)
|
|
63
|
+
} catch {
|
|
64
|
+
/* 信号实现不完整时忽略 */
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* 结算一个挂起记录。
|
|
71
|
+
* @param {string} sessionId
|
|
72
|
+
* @param {string} reason
|
|
73
|
+
* @param {'release'|'reject'} outcome
|
|
74
|
+
* @returns {boolean} 是否真的结算了一条
|
|
75
|
+
*/
|
|
76
|
+
function settle(sessionId, reason, outcome = 'release') {
|
|
77
|
+
const key = String(sessionId ?? '')
|
|
78
|
+
const entry = holds.get(key)
|
|
79
|
+
if (!entry) return false
|
|
80
|
+
holds.delete(key)
|
|
81
|
+
clearTimer(entry)
|
|
82
|
+
if (outcome === 'reject') {
|
|
83
|
+
entry.reject(new PeakDeferredError(reason, { sessionId: key, provider: entry.provider, model: entry.model }))
|
|
84
|
+
} else {
|
|
85
|
+
entry.resolve(entry.config)
|
|
86
|
+
}
|
|
87
|
+
return true
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* 挂起一次请求。
|
|
92
|
+
* @param {string} sessionId
|
|
93
|
+
* @param {{provider?:string, model?:string, config?:unknown}} info
|
|
94
|
+
* @param {AbortSignal} [signal]
|
|
95
|
+
* @returns {Promise<unknown>} 退峰 resolve 原 config
|
|
96
|
+
*/
|
|
97
|
+
function hold(sessionId, info = {}, signal) {
|
|
98
|
+
const key = String(sessionId ?? '')
|
|
99
|
+
// 重复 hold:先结算旧的(新请求代表同一会话的最新意图)
|
|
100
|
+
if (holds.has(key)) settle(key, 'superseded', 'reject')
|
|
101
|
+
|
|
102
|
+
let resolveFn
|
|
103
|
+
let rejectFn
|
|
104
|
+
const promise = new Promise((resolve, reject) => {
|
|
105
|
+
resolveFn = resolve
|
|
106
|
+
rejectFn = reject
|
|
107
|
+
})
|
|
108
|
+
const entry = {
|
|
109
|
+
sessionId: key,
|
|
110
|
+
provider: info.provider ?? null,
|
|
111
|
+
model: info.model ?? null,
|
|
112
|
+
config: info.config,
|
|
113
|
+
since: now(),
|
|
114
|
+
resolve: resolveFn,
|
|
115
|
+
reject: rejectFn,
|
|
116
|
+
promise,
|
|
117
|
+
timer: null,
|
|
118
|
+
signal: null,
|
|
119
|
+
onAbort: null,
|
|
120
|
+
}
|
|
121
|
+
holds.set(key, entry)
|
|
122
|
+
|
|
123
|
+
if (Number.isFinite(resolveMaxHold()) && resolveMaxHold() > 0) {
|
|
124
|
+
entry.timer = setTimeout(() => {
|
|
125
|
+
if (holds.get(key) !== entry) return
|
|
126
|
+
settle(key, 'max-hold-exceeded', 'reject')
|
|
127
|
+
}, resolveMaxHold())
|
|
128
|
+
if (entry.timer && unrefTimers && typeof entry.timer.unref === 'function') entry.timer.unref()
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (signal && typeof signal.addEventListener === 'function') {
|
|
132
|
+
entry.signal = signal
|
|
133
|
+
if (signal.aborted) {
|
|
134
|
+
settle(key, 'aborted', 'reject')
|
|
135
|
+
} else {
|
|
136
|
+
entry.onAbort = () => {
|
|
137
|
+
if (holds.get(key) === entry) settle(key, 'aborted', 'reject')
|
|
138
|
+
}
|
|
139
|
+
signal.addEventListener('abort', entry.onAbort, { once: true })
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return promise
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** 放行全部挂起(退峰):resolve 原 config,并清空延后记录。 */
|
|
147
|
+
function releaseAll(reason = 'off-peak') {
|
|
148
|
+
const released = []
|
|
149
|
+
for (const key of [...holds.keys()]) {
|
|
150
|
+
if (settle(key, reason, 'release')) released.push(key)
|
|
151
|
+
}
|
|
152
|
+
const cleared = [...deferred.keys()]
|
|
153
|
+
deferred.clear()
|
|
154
|
+
return { released, cleared }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** 拒绝全部挂起(插件卸载 / 关闭自动续跑)。 */
|
|
158
|
+
function rejectAll(reason = 'disposed') {
|
|
159
|
+
const rejected = []
|
|
160
|
+
for (const key of [...holds.keys()]) {
|
|
161
|
+
if (settle(key, reason, 'reject')) rejected.push(key)
|
|
162
|
+
}
|
|
163
|
+
return rejected
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** error 模式:登记一条延后记录(退峰时按 deferredResume 决定是否 followup)。 */
|
|
167
|
+
function remember(sessionId, info = {}) {
|
|
168
|
+
const key = String(sessionId ?? '')
|
|
169
|
+
if (key === '') return null
|
|
170
|
+
const rec = {
|
|
171
|
+
sessionId: key,
|
|
172
|
+
provider: info.provider ?? null,
|
|
173
|
+
model: info.model ?? null,
|
|
174
|
+
reason: info.reason ?? 'peak',
|
|
175
|
+
since: now(),
|
|
176
|
+
}
|
|
177
|
+
deferred.set(key, rec)
|
|
178
|
+
return rec
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
hold,
|
|
183
|
+
settle,
|
|
184
|
+
releaseAll,
|
|
185
|
+
rejectAll,
|
|
186
|
+
remember,
|
|
187
|
+
/** 活跃挂起快照。 */
|
|
188
|
+
list: () => [...holds.values()].map((e) => ({ sessionId: e.sessionId, provider: e.provider, model: e.model, since: e.since })),
|
|
189
|
+
/** 延后记录快照。 */
|
|
190
|
+
deferredList: () => [...deferred.values()],
|
|
191
|
+
isDeferred: (sessionId) => deferred.has(String(sessionId ?? '')),
|
|
192
|
+
clearDeferred: (sessionId) => deferred.delete(String(sessionId ?? '')),
|
|
193
|
+
has: (sessionId) => holds.has(String(sessionId ?? '')),
|
|
194
|
+
size: () => holds.size,
|
|
195
|
+
deferredSize: () => deferred.size,
|
|
196
|
+
}
|
|
197
|
+
}
|