dsh-opencode-go-router 0.0.0-stage → 0.1.6
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.en.md +22 -0
- package/CHANGELOG.md +22 -0
- package/LICENSE +21 -0
- package/README.md +135 -2
- package/README.zh-CN.md +136 -0
- package/cordis.patch.yml +5 -0
- package/lib/core.js +345 -0
- package/lib/impl.js +428 -0
- package/lib/index.js +53 -0
- package/lib/monitor.js +528 -0
- package/lib/store.js +202 -0
- package/package.json +62 -4
package/lib/monitor.js
ADDED
|
@@ -0,0 +1,528 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session monitor: follows the billion-context state file for one DSH session,
|
|
3
|
+
* detects new fold rebuilds, and rotates the OpenCode routing id when the
|
|
4
|
+
* latest rebuild lost the prefix cache and recent requests are slow.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import fsp from 'node:fs/promises';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import {
|
|
10
|
+
biliSessionKey,
|
|
11
|
+
biliSessionsRoot,
|
|
12
|
+
biliStatePathCandidates,
|
|
13
|
+
decideRotation,
|
|
14
|
+
decideSystemRewriteRotation,
|
|
15
|
+
extractFolds,
|
|
16
|
+
maxFoldSeq,
|
|
17
|
+
newRoutingId,
|
|
18
|
+
round1,
|
|
19
|
+
summarizeSamples,
|
|
20
|
+
systemPromptFingerprint,
|
|
21
|
+
} from './core.js';
|
|
22
|
+
|
|
23
|
+
/** Ignore a fold that lands immediately after a system-rewrite rotation. */
|
|
24
|
+
const SYSTEM_REWRITE_FOLD_GUARD_MS = 120000;
|
|
25
|
+
|
|
26
|
+
async function exists(filePath) {
|
|
27
|
+
try {
|
|
28
|
+
await fsp.access(filePath);
|
|
29
|
+
return true;
|
|
30
|
+
} catch {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function scanForStateFile(root, key) {
|
|
36
|
+
let entries;
|
|
37
|
+
try {
|
|
38
|
+
entries = await fsp.readdir(root, { withFileTypes: true });
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
const dirs = entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name);
|
|
43
|
+
for (const dir of dirs) {
|
|
44
|
+
let names;
|
|
45
|
+
try {
|
|
46
|
+
names = await fsp.readdir(path.join(root, dir));
|
|
47
|
+
} catch {
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
const hit = names.find((name) => name.endsWith(key + '.json'));
|
|
51
|
+
if (hit) return path.join(root, dir, hit);
|
|
52
|
+
}
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function errorText(error) {
|
|
57
|
+
if (error && typeof error.message === 'string') return error.message;
|
|
58
|
+
return String(error);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export class SessionMonitor {
|
|
62
|
+
constructor(options) {
|
|
63
|
+
const opts = options || {};
|
|
64
|
+
this.sessionId = String(opts.sessionId);
|
|
65
|
+
this.config = opts.config;
|
|
66
|
+
this.store = opts.store;
|
|
67
|
+
this.logger = opts.logger || console;
|
|
68
|
+
this.onRotate = typeof opts.onRotate === 'function' ? opts.onRotate : null;
|
|
69
|
+
this.now = typeof opts.now === 'function' ? opts.now : Date.now;
|
|
70
|
+
this.filePath = null;
|
|
71
|
+
this.lastStatKey = null;
|
|
72
|
+
this.baselineReady = false;
|
|
73
|
+
this.lastFoldSeq = null;
|
|
74
|
+
this.latestFold = null;
|
|
75
|
+
this.folds = [];
|
|
76
|
+
this.ledgerSeen = false;
|
|
77
|
+
this.lastRequestAt = 0;
|
|
78
|
+
this.lastTickAt = 0;
|
|
79
|
+
this.lastError = null;
|
|
80
|
+
this.rotations = 0;
|
|
81
|
+
this.systemRewriteRotations = 0;
|
|
82
|
+
this.lastSystemPromptFp = null;
|
|
83
|
+
this.pendingSystemRewrite = false;
|
|
84
|
+
this.lastSystemRewriteAt = null;
|
|
85
|
+
this.skipFoldRotationUntil = 0;
|
|
86
|
+
// The latest ordinary conversation request was an eligible OpenCode Go
|
|
87
|
+
// DeepSeek-series request. A non-target request clears this until the
|
|
88
|
+
// conversation switches back; default true is safe because the monitor is
|
|
89
|
+
// created from a target stream/header and the header hook updates it.
|
|
90
|
+
this.targetActive = true;
|
|
91
|
+
this.ticking = null;
|
|
92
|
+
this.timers = new Set();
|
|
93
|
+
this.lastResolveAt = 0;
|
|
94
|
+
this.resolveBackoffUntil = 0;
|
|
95
|
+
/**
|
|
96
|
+
* billion-context keys its state file by its own conversation identity,
|
|
97
|
+
* which is stable for a DSH session and is NOT changed by rotating
|
|
98
|
+
* x-opencode-session. The rotated header only selects the upstream backend;
|
|
99
|
+
* it does not open a new billion-context conversation. Keep watching the
|
|
100
|
+
* same DSH session state file across rotations so later folds remain visible.
|
|
101
|
+
*/
|
|
102
|
+
this.watchedIdentity = this.sessionId;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
debug(line) {
|
|
106
|
+
if (!this.config.debug) return;
|
|
107
|
+
try {
|
|
108
|
+
if (this.logger && typeof this.logger.info === 'function') this.logger.info('[opencode-router] ' + line);
|
|
109
|
+
} catch {
|
|
110
|
+
// Ignore logging failures.
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
warn(line) {
|
|
115
|
+
try {
|
|
116
|
+
if (this.logger && typeof this.logger.warn === 'function') this.logger.warn('[opencode-router] ' + line);
|
|
117
|
+
} catch {
|
|
118
|
+
// Ignore logging failures.
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
touch() {
|
|
123
|
+
this.lastRequestAt = this.now();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
setTargetActive(active) {
|
|
127
|
+
const next = active === true;
|
|
128
|
+
if (this.targetActive === next) return;
|
|
129
|
+
this.targetActive = next;
|
|
130
|
+
this.debug('target model ' + (next ? 'eligible' : 'not eligible') + ' for ' + this.sessionId);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Compare the initial system prompt of an outgoing request with the previous
|
|
135
|
+
* one. The first observation only establishes a baseline. A fingerprint
|
|
136
|
+
* change means the prefix cache is expected to miss regardless of the last
|
|
137
|
+
* fold's measured cache hit.
|
|
138
|
+
*/
|
|
139
|
+
observeSystemPrompt(text) {
|
|
140
|
+
const fingerprint = systemPromptFingerprint(text);
|
|
141
|
+
const previous = this.lastSystemPromptFp;
|
|
142
|
+
this.lastSystemPromptFp = fingerprint;
|
|
143
|
+
const changed = previous != null && previous !== fingerprint;
|
|
144
|
+
if (changed) {
|
|
145
|
+
this.pendingSystemRewrite = true;
|
|
146
|
+
this.debug('system prompt changed for ' + this.sessionId + ' (' + previous + ' -> ' + fingerprint + ')');
|
|
147
|
+
}
|
|
148
|
+
return { changed, previous, fingerprint };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Rotate before sending a request whose system prompt changed. This merges
|
|
153
|
+
* the route switch's cold prefix with the system rewrite's unavoidable cold
|
|
154
|
+
* prefix instead of paying for two separate cache misses. Only slow samples
|
|
155
|
+
* are required; the previous fold's cache hit is intentionally ignored.
|
|
156
|
+
*/
|
|
157
|
+
rotateForSystemRewrite() {
|
|
158
|
+
if (!this.pendingSystemRewrite) return null;
|
|
159
|
+
// This rewrite's request is about to be sent; do not reuse the pending flag
|
|
160
|
+
// for a later request if this attempt does not rotate.
|
|
161
|
+
this.pendingSystemRewrite = false;
|
|
162
|
+
if (this.config.systemRewriteBypassCache !== true) {
|
|
163
|
+
this.debug('system-rewrite rotation disabled for ' + this.sessionId);
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
if (this.targetActive !== true) {
|
|
167
|
+
this.debug('system-rewrite rotation skipped: latest conversation request is not an OpenCode Go DeepSeek model');
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
const existing = this.store.routeFor(this.sessionId);
|
|
171
|
+
if (existing && this.config.minRotationGapMs > 0) {
|
|
172
|
+
const rotatedAt = Number(existing.rotatedAt) || 0;
|
|
173
|
+
if (this.now() - rotatedAt < this.config.minRotationGapMs) {
|
|
174
|
+
this.debug('system-rewrite rotation skipped by cooldown for ' + this.sessionId);
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
const decision = decideSystemRewriteRotation(this.store.samplesFor(this.sessionId), this.config);
|
|
179
|
+
if (!decision.rotate) {
|
|
180
|
+
this.debug('system prompt changed but no rotation: ' + decision.reason);
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
const record = {
|
|
184
|
+
value: newRoutingId(this.config.routingIdPrefix),
|
|
185
|
+
rotatedAt: this.now(),
|
|
186
|
+
foldSeq: null,
|
|
187
|
+
reason: 'system-rewrite',
|
|
188
|
+
cacheHitPct: null,
|
|
189
|
+
avgTtftMs: decision.performance ? round1(decision.performance.avgTtftMs) : null,
|
|
190
|
+
avgTps: decision.performance ? round1(decision.performance.avgTps) : null,
|
|
191
|
+
};
|
|
192
|
+
if (!this.config.dryRun) {
|
|
193
|
+
this.store.setRoute(this.sessionId, record);
|
|
194
|
+
}
|
|
195
|
+
this.rotations += 1;
|
|
196
|
+
this.systemRewriteRotations += 1;
|
|
197
|
+
this.lastSystemRewriteAt = record.rotatedAt;
|
|
198
|
+
// A fold rebuild caused by the same prompt rewrite will show up shortly.
|
|
199
|
+
// Do not rotate a second time for that fold.
|
|
200
|
+
this.skipFoldRotationUntil = record.rotatedAt + SYSTEM_REWRITE_FOLD_GUARD_MS;
|
|
201
|
+
this.warn(
|
|
202
|
+
'rotated x-opencode-session for ' + this.sessionId
|
|
203
|
+
+ ' at a system prompt rewrite (cache condition bypassed, avg TTFT '
|
|
204
|
+
+ (record.avgTtftMs == null ? 'n/a' : record.avgTtftMs + 'ms')
|
|
205
|
+
+ ', avg ' + (record.avgTps == null ? 'n/a' : record.avgTps + ' tok/s')
|
|
206
|
+
+ ')' + (this.config.dryRun ? ' [dry-run]' : ''),
|
|
207
|
+
);
|
|
208
|
+
if (this.onRotate) {
|
|
209
|
+
try {
|
|
210
|
+
this.onRotate({ sessionId: this.sessionId, record, fold: null, decision, dryRun: this.config.dryRun });
|
|
211
|
+
} catch {
|
|
212
|
+
// A status listener must not break rotation.
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return record;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
scheduleRefresh(delays) {
|
|
219
|
+
const list = Array.isArray(delays) ? delays : [];
|
|
220
|
+
for (const raw of list) {
|
|
221
|
+
const delay = Math.max(0, Number(raw) || 0);
|
|
222
|
+
const fire = () => {
|
|
223
|
+
this.timers.delete(timer);
|
|
224
|
+
this.tick().catch(() => {});
|
|
225
|
+
};
|
|
226
|
+
const timer = setTimeout(fire, delay);
|
|
227
|
+
if (timer && typeof timer.unref === 'function') timer.unref();
|
|
228
|
+
this.timers.add(timer);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async resolveFile() {
|
|
233
|
+
const identity = this.sessionId;
|
|
234
|
+
if (this.filePath && await exists(this.filePath)) return this.filePath;
|
|
235
|
+
if (this.filePath) {
|
|
236
|
+
this.filePath = null;
|
|
237
|
+
this.lastStatKey = null;
|
|
238
|
+
this.baselineReady = false;
|
|
239
|
+
this.lastFoldSeq = null;
|
|
240
|
+
}
|
|
241
|
+
const now = this.now();
|
|
242
|
+
if (now < this.resolveBackoffUntil) return null;
|
|
243
|
+
if (now - this.lastResolveAt < 1000) return null;
|
|
244
|
+
this.lastResolveAt = now;
|
|
245
|
+
|
|
246
|
+
for (const candidate of biliStatePathCandidates(identity, this.config)) {
|
|
247
|
+
if (await exists(candidate)) {
|
|
248
|
+
this.filePath = candidate;
|
|
249
|
+
return candidate;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
const discovered = await scanForStateFile(biliSessionsRoot(this.config), biliSessionKey(identity));
|
|
253
|
+
if (discovered) {
|
|
254
|
+
this.filePath = discovered;
|
|
255
|
+
return discovered;
|
|
256
|
+
}
|
|
257
|
+
this.resolveBackoffUntil = now + 2000;
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
tick() {
|
|
262
|
+
if (this.ticking) return this.ticking;
|
|
263
|
+
this.ticking = this.tickOnce()
|
|
264
|
+
.catch((error) => {
|
|
265
|
+
this.lastError = errorText(error);
|
|
266
|
+
this.warn('monitor tick failed: ' + this.lastError);
|
|
267
|
+
})
|
|
268
|
+
.finally(() => {
|
|
269
|
+
this.ticking = null;
|
|
270
|
+
});
|
|
271
|
+
return this.ticking;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async tickOnce() {
|
|
275
|
+
this.lastTickAt = this.now();
|
|
276
|
+
const filePath = await this.resolveFile();
|
|
277
|
+
if (!filePath) return;
|
|
278
|
+
this.filePath = filePath;
|
|
279
|
+
|
|
280
|
+
let stat;
|
|
281
|
+
try {
|
|
282
|
+
stat = await fsp.stat(filePath);
|
|
283
|
+
} catch (error) {
|
|
284
|
+
this.lastError = 'stat: ' + errorText(error);
|
|
285
|
+
this.filePath = null;
|
|
286
|
+
this.lastStatKey = null;
|
|
287
|
+
this.baselineReady = false;
|
|
288
|
+
this.lastFoldSeq = null;
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
const statKey = String(stat.mtimeMs) + ':' + String(stat.size);
|
|
292
|
+
if (statKey === this.lastStatKey && this.baselineReady) return;
|
|
293
|
+
this.lastStatKey = statKey;
|
|
294
|
+
|
|
295
|
+
let json;
|
|
296
|
+
try {
|
|
297
|
+
json = JSON.parse(await fsp.readFile(filePath, 'utf8'));
|
|
298
|
+
} catch (error) {
|
|
299
|
+
this.lastStatKey = null;
|
|
300
|
+
this.lastError = 'parse: ' + errorText(error);
|
|
301
|
+
this.warn('state file parse failed: ' + this.lastError);
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const root = json && typeof json === 'object' && json.payload ? json.payload : json;
|
|
306
|
+
this.ledgerSeen = Boolean(root && root.metadata && root.metadata.cacheLedger);
|
|
307
|
+
const folds = extractFolds(json);
|
|
308
|
+
const maxSeq = maxFoldSeq(folds);
|
|
309
|
+
|
|
310
|
+
this.folds = folds;
|
|
311
|
+
this.latestFold = folds.length > 0 ? folds[folds.length - 1] : null;
|
|
312
|
+
this.lastError = null;
|
|
313
|
+
|
|
314
|
+
if (!this.baselineReady) {
|
|
315
|
+
this.baselineReady = true;
|
|
316
|
+
this.lastFoldSeq = maxSeq;
|
|
317
|
+
this.debug('baseline session=' + this.sessionId + ' lastFoldSeq=' + maxSeq + ' folds=' + folds.length);
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (maxSeq < this.lastFoldSeq) {
|
|
322
|
+
this.warn('fold sequence went backwards for ' + this.sessionId + ' (' + this.lastFoldSeq + ' -> ' + maxSeq + '); rebaselining');
|
|
323
|
+
this.lastFoldSeq = maxSeq;
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
if (maxSeq === this.lastFoldSeq) return;
|
|
327
|
+
|
|
328
|
+
const firstNew = folds.find((fold) => fold.seq > this.lastFoldSeq);
|
|
329
|
+
if (!firstNew) {
|
|
330
|
+
this.lastFoldSeq = maxSeq;
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
const previous = folds.filter((fold) => fold.seq < firstNew.seq);
|
|
334
|
+
const samples = this.store.samplesFor(this.sessionId);
|
|
335
|
+
const decision = decideRotation({
|
|
336
|
+
previousFolds: previous,
|
|
337
|
+
samples,
|
|
338
|
+
config: this.config,
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
if (decision.rotate) {
|
|
342
|
+
this.rotate(firstNew, decision);
|
|
343
|
+
// The state file does not change on rotation, so mark this fold as seen
|
|
344
|
+
// and keep the same file/baseline. Otherwise later writes with the same
|
|
345
|
+
// fold list would rotate again for the same rebuild.
|
|
346
|
+
this.lastFoldSeq = maxSeq;
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
this.debug(
|
|
350
|
+
'new fold #' + firstNew.seq + ' but no rotation: ' + decision.reason
|
|
351
|
+
+ ' cache=' + decision.cache.reason
|
|
352
|
+
+ ' perf=' + (decision.performance ? decision.performance.reason : 'n/a'),
|
|
353
|
+
);
|
|
354
|
+
this.lastFoldSeq = maxSeq;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
rotate(fold, decision) {
|
|
358
|
+
if (this.now() < this.skipFoldRotationUntil) {
|
|
359
|
+
this.debug('fold rotation skipped: already rotated for a system prompt rewrite');
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
if (this.targetActive !== true) {
|
|
363
|
+
this.debug('rotation skipped: latest conversation request is not an OpenCode Go DeepSeek model');
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
const existing = this.store.routeFor(this.sessionId);
|
|
367
|
+
if (existing && this.config.minRotationGapMs > 0) {
|
|
368
|
+
const rotatedAt = Number(existing.rotatedAt) || 0;
|
|
369
|
+
if (this.now() - rotatedAt < this.config.minRotationGapMs) {
|
|
370
|
+
this.debug('rotation skipped by cooldown for ' + this.sessionId);
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
const value = newRoutingId(this.config.routingIdPrefix);
|
|
375
|
+
const recentHit = decision.cache.recent.length > 0
|
|
376
|
+
? decision.cache.recent[decision.cache.recent.length - 1].hPct
|
|
377
|
+
: null;
|
|
378
|
+
const record = {
|
|
379
|
+
value,
|
|
380
|
+
rotatedAt: this.now(),
|
|
381
|
+
foldSeq: fold.seq,
|
|
382
|
+
reason: decision.reason,
|
|
383
|
+
cacheHitPct: recentHit,
|
|
384
|
+
avgTtftMs: decision.performance ? round1(decision.performance.avgTtftMs) : null,
|
|
385
|
+
avgTps: decision.performance ? round1(decision.performance.avgTps) : null,
|
|
386
|
+
};
|
|
387
|
+
if (!this.config.dryRun) {
|
|
388
|
+
this.store.setRoute(this.sessionId, record);
|
|
389
|
+
}
|
|
390
|
+
this.rotations += 1;
|
|
391
|
+
const message = 'rotated x-opencode-session for ' + this.sessionId
|
|
392
|
+
+ ' at fold #' + fold.seq
|
|
393
|
+
+ ' (cache ' + (recentHit == null ? 'n/a' : recentHit + '%')
|
|
394
|
+
+ ', avg TTFT ' + (record.avgTtftMs == null ? 'n/a' : record.avgTtftMs + 'ms')
|
|
395
|
+
+ ', avg ' + (record.avgTps == null ? 'n/a' : record.avgTps + ' tok/s')
|
|
396
|
+
+ ')' + (this.config.dryRun ? ' [dry-run]' : '');
|
|
397
|
+
this.warn(message);
|
|
398
|
+
if (this.onRotate) {
|
|
399
|
+
try {
|
|
400
|
+
this.onRotate({ sessionId: this.sessionId, record, fold, decision, dryRun: this.config.dryRun });
|
|
401
|
+
} catch {
|
|
402
|
+
// A status listener must not break rotation.
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
status() {
|
|
408
|
+
const perf = summarizeSamples(this.store.samplesFor(this.sessionId), this.config.sampleSize);
|
|
409
|
+
return {
|
|
410
|
+
sessionId: this.sessionId,
|
|
411
|
+
stateFile: this.filePath,
|
|
412
|
+
ledgerSeen: this.ledgerSeen,
|
|
413
|
+
baselineReady: this.baselineReady,
|
|
414
|
+
lastFoldSeq: this.lastFoldSeq,
|
|
415
|
+
latestFold: this.latestFold,
|
|
416
|
+
foldsSeen: this.folds.length,
|
|
417
|
+
// Informational only: billion-context's state file is keyed by the DSH
|
|
418
|
+
// session id, not by the rotated x-opencode-session value.
|
|
419
|
+
watchingId: this.sessionId,
|
|
420
|
+
route: this.store.routeFor(this.sessionId),
|
|
421
|
+
targetActive: this.targetActive,
|
|
422
|
+
systemPromptFp: this.lastSystemPromptFp,
|
|
423
|
+
systemRewriteBypassCache: this.config.systemRewriteBypassCache,
|
|
424
|
+
systemRewriteRotations: this.systemRewriteRotations,
|
|
425
|
+
lastSystemRewriteAt: this.lastSystemRewriteAt,
|
|
426
|
+
samples: perf,
|
|
427
|
+
rotations: this.rotations,
|
|
428
|
+
lastRequestAt: this.lastRequestAt,
|
|
429
|
+
lastTickAt: this.lastTickAt,
|
|
430
|
+
lastError: this.lastError,
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
dispose() {
|
|
435
|
+
for (const timer of this.timers) clearTimeout(timer);
|
|
436
|
+
this.timers.clear();
|
|
437
|
+
this.ticking = null;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
export class RouterMonitor {
|
|
442
|
+
constructor(options) {
|
|
443
|
+
const opts = options || {};
|
|
444
|
+
this.config = opts.config;
|
|
445
|
+
this.store = opts.store;
|
|
446
|
+
this.logger = opts.logger || console;
|
|
447
|
+
this.onRotate = typeof opts.onRotate === 'function' ? opts.onRotate : null;
|
|
448
|
+
this.now = typeof opts.now === 'function' ? opts.now : Date.now;
|
|
449
|
+
this.monitors = new Map();
|
|
450
|
+
this.timer = null;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
ensure(sessionId) {
|
|
454
|
+
const key = String(sessionId == null ? '' : sessionId);
|
|
455
|
+
if (key === '') return null;
|
|
456
|
+
let monitor = this.monitors.get(key);
|
|
457
|
+
if (!monitor) {
|
|
458
|
+
monitor = new SessionMonitor({
|
|
459
|
+
sessionId: key,
|
|
460
|
+
config: this.config,
|
|
461
|
+
store: this.store,
|
|
462
|
+
logger: this.logger,
|
|
463
|
+
onRotate: this.onRotate,
|
|
464
|
+
now: this.now,
|
|
465
|
+
});
|
|
466
|
+
this.monitors.set(key, monitor);
|
|
467
|
+
}
|
|
468
|
+
return monitor;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
noteRequestModel(sessionId, target) {
|
|
472
|
+
const key = String(sessionId == null ? '' : sessionId);
|
|
473
|
+
if (key === '') return;
|
|
474
|
+
if (target === true) {
|
|
475
|
+
this.ensure(key).setTargetActive(true);
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
const monitor = this.monitors.get(key);
|
|
479
|
+
if (monitor) monitor.setTargetActive(false);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
observeSystemPrompt(sessionId, text) {
|
|
483
|
+
const monitor = this.ensure(sessionId);
|
|
484
|
+
if (!monitor) return null;
|
|
485
|
+
const observed = monitor.observeSystemPrompt(text);
|
|
486
|
+
if (observed.changed) monitor.rotateForSystemRewrite();
|
|
487
|
+
return observed;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
noteRequestFinished(sessionId) {
|
|
491
|
+
const monitor = this.ensure(sessionId);
|
|
492
|
+
if (!monitor) return;
|
|
493
|
+
monitor.touch();
|
|
494
|
+
monitor.scheduleRefresh(this.config.requestCheckDelaysMs);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
start() {
|
|
498
|
+
if (this.timer) return;
|
|
499
|
+
this.timer = setInterval(() => {
|
|
500
|
+
this.tickActive().catch(() => {});
|
|
501
|
+
}, this.config.pollIntervalMs);
|
|
502
|
+
if (this.timer && typeof this.timer.unref === 'function') this.timer.unref();
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
async tickActive() {
|
|
506
|
+
const now = this.now();
|
|
507
|
+
for (const monitor of this.monitors.values()) {
|
|
508
|
+
if (now - monitor.lastRequestAt <= this.config.sessionActiveMs) {
|
|
509
|
+
await monitor.tick();
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
async tickAll() {
|
|
515
|
+
for (const monitor of this.monitors.values()) await monitor.tick();
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
status() {
|
|
519
|
+
return [...this.monitors.values()].map((monitor) => monitor.status());
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
dispose() {
|
|
523
|
+
if (this.timer) clearInterval(this.timer);
|
|
524
|
+
this.timer = null;
|
|
525
|
+
for (const monitor of this.monitors.values()) monitor.dispose();
|
|
526
|
+
this.monitors.clear();
|
|
527
|
+
}
|
|
528
|
+
}
|