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/lib/impl.js ADDED
@@ -0,0 +1,428 @@
1
+ /**
2
+ * opencode-router - implementation.
3
+ *
4
+ * Two host hooks:
5
+ * - llm-pi-ai/request-headers: stamp the rotated x-opencode-session value.
6
+ * - llm/stream: measure TTFT/throughput without changing the stream.
7
+ * Plus a small monitor over billion-context's fold ledger and a status route.
8
+ */
9
+
10
+ import fs from 'node:fs';
11
+ import {
12
+ CONTENT_CHUNK_TYPES,
13
+ NAME,
14
+ TARGET_MODEL_PREFIX,
15
+ VERSION,
16
+ biliSessionsRoot,
17
+ isTargetRequest,
18
+ normalizeConfig,
19
+ systemPromptText,
20
+ } from './core.js';
21
+ import { RouterMonitor } from './monitor.js';
22
+ import { RouterStore } from './store.js';
23
+
24
+ const STATUS_ROUTE = '/opencode-router/status';
25
+
26
+ function createLogger(ctx) {
27
+ const write = (level, args) => {
28
+ try {
29
+ const logger = ctx && ctx.logger;
30
+ const fn = logger && logger[level];
31
+ if (typeof fn === 'function') {
32
+ fn.apply(logger, args);
33
+ return;
34
+ }
35
+ } catch {
36
+ // Fall through to console.
37
+ }
38
+ try {
39
+ const fn = console[level] || console.log;
40
+ if (typeof fn === 'function') fn.apply(console, args);
41
+ } catch {
42
+ // Logging must never break the host.
43
+ }
44
+ };
45
+ return {
46
+ info: (...args) => write('info', args),
47
+ warn: (...args) => write('warn', args),
48
+ error: (...args) => write('error', args),
49
+ debug: (...args) => write('debug', args),
50
+ };
51
+ }
52
+
53
+ function errorText(error) {
54
+ if (error && typeof error.message === 'string') return error.message;
55
+ return String(error);
56
+ }
57
+
58
+ function sessionKeyOf(value) {
59
+ if (value == null) return '';
60
+ return String(value);
61
+ }
62
+
63
+ /**
64
+ * Return the rotated routing id for a request, or null when no rotation applies.
65
+ * Kept separate from the waterfall handler so it is easy to unit test.
66
+ */
67
+ export function routingForRequest(request, config, store) {
68
+ if (!config.enabled || !store) return null;
69
+ if (!isTargetRequest(request, config)) return null;
70
+ const sessionId = sessionKeyOf(request && request.sessionId);
71
+ if (sessionId === '') return null;
72
+ const route = store.routeFor(sessionId);
73
+ if (!route || typeof route.value !== 'string' || route.value === '') return null;
74
+ return route;
75
+ }
76
+
77
+ export function applyRoutingHeaderDetailed(request, headers, config, store) {
78
+ if (!headers || typeof headers.set !== 'function' || typeof headers.get !== 'function') {
79
+ return { applied: false, route: null, reason: 'no-headers' };
80
+ }
81
+ if (!config.enabled) return { applied: false, route: null, reason: 'disabled' };
82
+ // The strategy is only for OpenCode Go DeepSeek-series conversations. Other
83
+ // models must never receive the rotated header, even if a route exists.
84
+ if (!isTargetRequest(request, config)) {
85
+ return { applied: false, route: null, reason: 'not-target' };
86
+ }
87
+ const route = routingForRequest(request, config, store);
88
+ if (!route) return { applied: false, route: null, reason: 'no-route' };
89
+ if (!config.overrideExistingHeader) {
90
+ const current = headers.get('x-opencode-session');
91
+ if (current != null && String(current).trim() !== '') {
92
+ return { applied: false, route, reason: 'existing-header' };
93
+ }
94
+ }
95
+ headers.set('x-opencode-session', route.value);
96
+ return { applied: true, route, reason: 'applied' };
97
+ }
98
+
99
+ export function applyRoutingHeader(request, headers, config, store) {
100
+ return applyRoutingHeaderDetailed(request, headers, config, store).applied;
101
+ }
102
+
103
+ /**
104
+ * Record one completed stream. Invalid/aborted samples are ignored; the monitor
105
+ * still gets a post-request nudge so the fold file is checked promptly.
106
+ */
107
+ function recordMeasurement(state, measurement) {
108
+ const { store, monitor, config, sessionId } = state;
109
+ try {
110
+ if (measurement.outputTokens > 0
111
+ && measurement.firstTokenAt != null
112
+ && measurement.endedAt > measurement.firstTokenAt) {
113
+ const tps = measurement.outputTokens / ((measurement.endedAt - measurement.firstTokenAt) / 1000);
114
+ if (Number.isFinite(tps) && tps > 0) {
115
+ store.recordSample(sessionId, {
116
+ ttftMs: Math.max(0, measurement.firstTokenAt - measurement.startedAt),
117
+ tps,
118
+ outputTokens: measurement.outputTokens,
119
+ at: measurement.startedAt,
120
+ }, config.maxSamples);
121
+ }
122
+ }
123
+ } catch (error) {
124
+ try {
125
+ state.logger.warn('[opencode-router] sample record failed: ' + errorText(error));
126
+ } catch {
127
+ // Ignore.
128
+ }
129
+ }
130
+ try {
131
+ monitor.noteRequestFinished(sessionId);
132
+ } catch {
133
+ // Monitoring is best effort.
134
+ }
135
+ }
136
+
137
+ export async function* measureStream(source, state) {
138
+ const startedAt = state.startedAt;
139
+ let firstTokenAt = null;
140
+ let outputTokens = 0;
141
+ let sawFinish = false;
142
+ try {
143
+ for await (const chunk of source) {
144
+ if (chunk && typeof chunk === 'object') {
145
+ const type = chunk.type;
146
+ if (firstTokenAt == null && CONTENT_CHUNK_TYPES.has(type)) firstTokenAt = Date.now();
147
+ if (type === 'usage' && chunk.usage && typeof chunk.usage === 'object') {
148
+ const value = Number(chunk.usage.outputTokens);
149
+ if (Number.isFinite(value) && value > 0) outputTokens = value;
150
+ }
151
+ if (type === 'finish') sawFinish = true;
152
+ }
153
+ yield chunk;
154
+ }
155
+ } finally {
156
+ if (sawFinish) {
157
+ recordMeasurement(state, {
158
+ startedAt,
159
+ firstTokenAt,
160
+ outputTokens,
161
+ endedAt: Date.now(),
162
+ });
163
+ }
164
+ }
165
+ }
166
+
167
+ function isMethod(req, expected) {
168
+ const method = String((req && req.method) || 'GET').toUpperCase();
169
+ return method === expected;
170
+ }
171
+
172
+ function sendJson(res, status, payload) {
173
+ let body = 'null';
174
+ try {
175
+ body = JSON.stringify(payload);
176
+ } catch {
177
+ body = '{"error":"serialize failed"}';
178
+ }
179
+ try {
180
+ const length = Buffer.byteLength(body, 'utf8');
181
+ res.writeHead(status, {
182
+ 'content-type': 'application/json; charset=utf-8',
183
+ 'cache-control': 'no-store',
184
+ 'content-length': length,
185
+ });
186
+ res.end(body);
187
+ } catch {
188
+ try {
189
+ res.end();
190
+ } catch {
191
+ // Socket already gone.
192
+ }
193
+ }
194
+ }
195
+
196
+ function registerStatusRoute(ctx, getStatus, logger) {
197
+ try {
198
+ if (!ctx || typeof ctx.inject !== 'function') return;
199
+ ctx.inject(['webServer'], (sctx) => {
200
+ try {
201
+ const webServer = (sctx && sctx.webServer) || ctx.webServer;
202
+ if (!webServer || typeof webServer.register !== 'function') return;
203
+ const effectCtx = sctx && typeof sctx.effect === 'function' ? sctx : ctx;
204
+ const handler = (req, res) => {
205
+ try {
206
+ if (!isMethod(req, 'GET')) {
207
+ sendJson(res, 405, { error: 'method not allowed', allow: 'GET' });
208
+ return;
209
+ }
210
+ sendJson(res, 200, getStatus());
211
+ } catch (error) {
212
+ logger.warn('[opencode-router] status handler failed: ' + errorText(error));
213
+ sendJson(res, 500, { error: 'status failed', reason: errorText(error) });
214
+ }
215
+ };
216
+ if (typeof effectCtx.effect === 'function') {
217
+ effectCtx.effect(
218
+ () => webServer.register({ kind: 'exact', path: STATUS_ROUTE, handler }),
219
+ 'opencode-router: status route',
220
+ );
221
+ } else {
222
+ webServer.register({ kind: 'exact', path: STATUS_ROUTE, handler });
223
+ }
224
+ } catch (error) {
225
+ logger.warn('[opencode-router] status route registration failed: ' + errorText(error));
226
+ }
227
+ });
228
+ } catch (error) {
229
+ logger.warn('[opencode-router] webServer injection failed: ' + errorText(error));
230
+ }
231
+ }
232
+
233
+ export async function mount(ctx, rawConfig) {
234
+ const logger = createLogger(ctx);
235
+ const config = normalizeConfig(rawConfig);
236
+ const store = new RouterStore({ statePath: config.statePath, logger });
237
+ const monitor = new RouterMonitor({ config, store, logger });
238
+ let disposed = false;
239
+ // Observable proof that the waterfall actually stamped a request. This is
240
+ // deliberately small: counts and the most recent applied value, no prompts.
241
+ const routingStats = {
242
+ checks: 0,
243
+ applications: 0,
244
+ lastCheckedAt: null,
245
+ lastCheckedSessionId: null,
246
+ lastAppliedAt: null,
247
+ lastAppliedSessionId: null,
248
+ lastAppliedValue: null,
249
+ lastReason: null,
250
+ };
251
+
252
+ const dispose = () => {
253
+ if (disposed) return;
254
+ disposed = true;
255
+ try {
256
+ monitor.dispose();
257
+ } catch {
258
+ // Ignore cleanup failures.
259
+ }
260
+ Promise.resolve()
261
+ .then(() => store.dispose())
262
+ .catch(() => {});
263
+ try {
264
+ if (globalThis.__opencodeRouter && globalThis.__opencodeRouter.monitor === monitor) {
265
+ delete globalThis.__opencodeRouter;
266
+ }
267
+ } catch {
268
+ // Ignore.
269
+ }
270
+ };
271
+
272
+ const start = () => {
273
+ if (config.enabled) monitor.start();
274
+ };
275
+
276
+ const status = () => {
277
+ const snapshot = store.snapshot();
278
+ return {
279
+ name: NAME,
280
+ version: VERSION,
281
+ enabled: config.enabled,
282
+ dryRun: config.dryRun,
283
+ statePath: config.statePath,
284
+ config: { ...config },
285
+ eligibility: {
286
+ providers: [...config.providers],
287
+ modelPrefix: TARGET_MODEL_PREFIX,
288
+ },
289
+ updatedAt: snapshot.updatedAt,
290
+ routes: snapshot.routes,
291
+ routing: { ...routingStats },
292
+ sessions: monitor.status(),
293
+ };
294
+ };
295
+
296
+ const onHeaders = (request, headers, next) => {
297
+ try {
298
+ const target = isTargetRequest(request, config);
299
+ // Track the model of the latest ordinary conversation request. A
300
+ // non-DeepSeek request makes this session ineligible until a DeepSeek
301
+ // request returns. Auxiliary requests declare a purpose and are ignored.
302
+ if (request && request.purpose === undefined) {
303
+ const trackedSessionId = sessionKeyOf(request.sessionId);
304
+ if (trackedSessionId !== '') monitor.noteRequestModel(trackedSessionId, target);
305
+ }
306
+ const detail = applyRoutingHeaderDetailed(request, headers, config, store);
307
+ routingStats.checks += 1;
308
+ routingStats.lastCheckedAt = Date.now();
309
+ routingStats.lastCheckedSessionId = sessionKeyOf(request && request.sessionId);
310
+ routingStats.lastReason = detail.reason;
311
+ if (detail.applied) {
312
+ routingStats.applications += 1;
313
+ routingStats.lastAppliedAt = routingStats.lastCheckedAt;
314
+ routingStats.lastAppliedSessionId = routingStats.lastCheckedSessionId;
315
+ routingStats.lastAppliedValue = detail.route && detail.route.value ? detail.route.value : null;
316
+ if (config.debug) {
317
+ logger.info('[opencode-router] routed request for session ' + routingStats.lastAppliedSessionId);
318
+ }
319
+ }
320
+ } catch (error) {
321
+ try {
322
+ logger.warn('[opencode-router] header routing failed: ' + errorText(error));
323
+ } catch {
324
+ // Ignore.
325
+ }
326
+ }
327
+ return next();
328
+ };
329
+
330
+ const onStream = (options, next) => {
331
+ if (!config.enabled || !isTargetRequest(options, config)) return next();
332
+ // Auxiliary producers (titles, summaries, ...) declare a purpose; only
333
+ // ordinary conversation requests belong in the TTFT/throughput samples.
334
+ if (options && options.purpose !== undefined) return next();
335
+ const sessionId = sessionKeyOf(options && options.sessionId);
336
+ if (sessionId === '') return next();
337
+ // A target stream is itself proof that the latest conversation request is
338
+ // eligible, even if the previous ordinary request used another model.
339
+ monitor.noteRequestModel(sessionId, true);
340
+ // This hook sees the full request before the header waterfall runs. Detect
341
+ // a system-prompt rewrite here so a route written now applies to this very
342
+ // request, merging the route switch with the rewrite's unavoidable miss.
343
+ try {
344
+ const observed = monitor.observeSystemPrompt(sessionId, systemPromptText(options));
345
+ if (observed && observed.changed && config.debug) {
346
+ logger.info('[opencode-router] system prompt changed for session ' + sessionId);
347
+ }
348
+ } catch (error) {
349
+ logger.warn('[opencode-router] system prompt check failed: ' + errorText(error));
350
+ }
351
+ const startedAt = Date.now();
352
+ const source = next();
353
+ return measureStream(source, {
354
+ startedAt,
355
+ sessionId,
356
+ config,
357
+ store,
358
+ monitor,
359
+ logger,
360
+ });
361
+ };
362
+
363
+ try {
364
+ ctx.on('llm-pi-ai/request-headers', onHeaders, { prepend: true });
365
+ } catch (error) {
366
+ logger.warn('[opencode-router] request-header hook unavailable: ' + errorText(error));
367
+ }
368
+
369
+ try {
370
+ if (typeof ctx.inject === 'function') {
371
+ ctx.inject(['llm'], () => {
372
+ try {
373
+ ctx.on('llm/stream', onStream, { prepend: true });
374
+ } catch (error) {
375
+ logger.warn('[opencode-router] llm/stream hook unavailable: ' + errorText(error));
376
+ }
377
+ });
378
+ } else {
379
+ ctx.on('llm/stream', onStream, { prepend: true });
380
+ }
381
+ } catch (error) {
382
+ logger.warn('[opencode-router] llm injection failed: ' + errorText(error));
383
+ }
384
+
385
+ registerStatusRoute(ctx, status, logger);
386
+
387
+ try {
388
+ if (typeof ctx.effect === 'function') {
389
+ ctx.effect(() => {
390
+ start();
391
+ return dispose;
392
+ }, 'opencode-router: lifecycle');
393
+ } else {
394
+ start();
395
+ ctx.on('dispose', dispose);
396
+ }
397
+ } catch (error) {
398
+ logger.warn('[opencode-router] lifecycle registration failed: ' + errorText(error));
399
+ start();
400
+ }
401
+
402
+ try {
403
+ globalThis.__opencodeRouter = { config, store, monitor, status };
404
+ } catch {
405
+ // Ignore.
406
+ }
407
+
408
+ if (config.debug) {
409
+ logger.info(
410
+ '[opencode-router] mounted enabled=' + config.enabled
411
+ + ' dryRun=' + config.dryRun
412
+ + ' state=' + config.statePath
413
+ + ' model=' + TARGET_MODEL_PREFIX + '*'
414
+ + ' cache<=' + config.cacheHitThresholdPct + '%'
415
+ + ' ttft>=' + config.ttftThresholdMs + 'ms'
416
+ + ' tps<=' + config.throughputThresholdTps,
417
+ );
418
+ }
419
+
420
+ try {
421
+ const root = biliSessionsRoot(config);
422
+ if (!fs.existsSync(root)) {
423
+ logger.info('[opencode-router] billion-context sessions not found; monitor idle (' + root + ')');
424
+ }
425
+ } catch {
426
+ // Informational only.
427
+ }
428
+ }
package/lib/index.js ADDED
@@ -0,0 +1,53 @@
1
+ /**
2
+ * opencode-router - host plugin shell.
3
+ *
4
+ * DSH's plugin loader caches modules by package name, so this shell keeps almost
5
+ * no state: every apply reloads impl.js with a unique query string. Editing the
6
+ * implementation and touching the plugin config therefore takes effect without
7
+ * a full DSH restart. All failures are isolated here; a broken router must never
8
+ * stop the host from starting.
9
+ */
10
+
11
+ export const name = 'opencode-router';
12
+
13
+ /** Optional services only: hooks and the status route attach when available. */
14
+ export const inject = [];
15
+
16
+ let reloadSeq = 0;
17
+
18
+ function logViaContext(ctx, text) {
19
+ try {
20
+ const logger = ctx && ctx.logger;
21
+ if (logger && typeof logger.warn === 'function') {
22
+ logger.warn(text);
23
+ return;
24
+ }
25
+ } catch {
26
+ // Fall through to console.
27
+ }
28
+ try {
29
+ console.warn(text);
30
+ } catch {
31
+ // Ignore.
32
+ }
33
+ }
34
+
35
+ function describeError(error) {
36
+ if (error && typeof error.stack === 'string') return error.stack;
37
+ return String(error);
38
+ }
39
+
40
+ export async function apply(ctx, rawConfig) {
41
+ try {
42
+ const href = new URL('./impl.js', import.meta.url).href
43
+ + '?v=' + Date.now() + '-' + (++reloadSeq);
44
+ const impl = await import(href);
45
+ if (!impl || typeof impl.mount !== 'function') {
46
+ logViaContext(ctx, '[opencode-router] impl.js did not export mount; plugin skipped');
47
+ return;
48
+ }
49
+ await impl.mount(ctx, rawConfig);
50
+ } catch (error) {
51
+ logViaContext(ctx, '[opencode-router] apply failed (plugin skipped, DSH unaffected): ' + describeError(error));
52
+ }
53
+ }