dsh-opencode-go-router 0.0.0-stage → 0.1.5

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/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
+ }