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/core.js ADDED
@@ -0,0 +1,345 @@
1
+ /**
2
+ * opencode-router - pure decision helpers.
3
+ *
4
+ * The plugin watches the billion-context fold ledger for the current DSH
5
+ * session. A fold is a context-compression rebuild; its hPct is the cache-hit
6
+ * percentage of the request sent immediately after that rebuild. When the
7
+ * latest fold(s) lost the reusable prefix cache and recent calls are slow, we
8
+ * rotate the OpenCode routing id (x-opencode-session) for the next request.
9
+ */
10
+
11
+ import { createHash, randomBytes } from 'node:crypto';
12
+ import os from 'node:os';
13
+ import path from 'node:path';
14
+
15
+ export const NAME = 'opencode-router';
16
+ export const VERSION = '0.1.6';
17
+
18
+ /**
19
+ * Only OpenCode Go's DeepSeek-series models are eligible. The check is a
20
+ * case-insensitive prefix match so new DeepSeek model ids keep working; an
21
+ * exact allow-list can still narrow it through config.models.
22
+ */
23
+ export const TARGET_MODEL_PREFIX = 'deepseek';
24
+
25
+ /** Chunks that represent actual model output for TTFT purposes. */
26
+ export const CONTENT_CHUNK_TYPES = new Set([
27
+ 'text-delta',
28
+ 'reasoning-delta',
29
+ 'tool-call-delta',
30
+ 'block-end',
31
+ ]);
32
+
33
+ export const DEFAULTS = Object.freeze({
34
+ enabled: true,
35
+ providers: Object.freeze(['opencode-go']),
36
+ models: Object.freeze([]),
37
+ cacheHitThresholdPct: 25,
38
+ foldsWindow: 1,
39
+ systemRewriteBypassCache: true,
40
+ ttftThresholdMs: 12000,
41
+ throughputThresholdTps: 60,
42
+ sampleSize: 5,
43
+ requireFullSamples: true,
44
+ routingIdPrefix: 'ses_',
45
+ pollIntervalMs: 2000,
46
+ requestCheckDelaysMs: Object.freeze([400, 1500]),
47
+ biliSessionsDir: null,
48
+ biliProtocol: 'openai',
49
+ biliUpstreamHost: 'opencode.ai',
50
+ statePath: null,
51
+ dryRun: false,
52
+ debug: false,
53
+ overrideExistingHeader: true,
54
+ maxSamples: 20,
55
+ sessionActiveMs: 600000,
56
+ minRotationGapMs: 0,
57
+ });
58
+
59
+ function numberInRange(value, fallback, min, max) {
60
+ const n = Number(value);
61
+ if (!Number.isFinite(n)) return fallback;
62
+ return Math.min(max, Math.max(min, n));
63
+ }
64
+
65
+ function stringList(value, fallback) {
66
+ if (!Array.isArray(value)) return [...fallback];
67
+ const out = value
68
+ .map((entry) => String(entry == null ? '' : entry).trim())
69
+ .filter((entry) => entry.length > 0);
70
+ return out.length > 0 ? out : [...fallback];
71
+ }
72
+
73
+ export function expandHome(value) {
74
+ const raw = String(value == null ? '' : value).trim();
75
+ if (raw === '') return '';
76
+ if (raw === '~') return os.homedir();
77
+ if (raw.startsWith('~/') || raw.startsWith('~\\')) {
78
+ return path.join(os.homedir(), raw.slice(2));
79
+ }
80
+ return path.resolve(raw);
81
+ }
82
+
83
+ export function dshHome(env = process.env) {
84
+ if (env && typeof env.DSH_HOME === 'string' && env.DSH_HOME.trim() !== '') {
85
+ return path.resolve(env.DSH_HOME);
86
+ }
87
+ return path.join(os.homedir(), '.dsh');
88
+ }
89
+
90
+ export function defaultStatePath() {
91
+ return path.join(dshHome(), 'storages', 'opencode-router', 'state.json');
92
+ }
93
+
94
+ export function normalizeConfig(raw) {
95
+ const source = raw && typeof raw === 'object' ? raw : {};
96
+ return {
97
+ enabled: source.enabled !== false,
98
+ providers: stringList(source.providers, DEFAULTS.providers),
99
+ models: stringList(source.models, DEFAULTS.models),
100
+ cacheHitThresholdPct: numberInRange(source.cacheHitThresholdPct, DEFAULTS.cacheHitThresholdPct, 0, 100),
101
+ foldsWindow: Math.round(numberInRange(source.foldsWindow, DEFAULTS.foldsWindow, 1, 20)),
102
+ systemRewriteBypassCache: source.systemRewriteBypassCache !== false,
103
+ ttftThresholdMs: numberInRange(source.ttftThresholdMs, DEFAULTS.ttftThresholdMs, 1, 3600000),
104
+ throughputThresholdTps: numberInRange(source.throughputThresholdTps, DEFAULTS.throughputThresholdTps, 0.01, 1000000),
105
+ sampleSize: Math.round(numberInRange(source.sampleSize, DEFAULTS.sampleSize, 1, 50)),
106
+ requireFullSamples: source.requireFullSamples !== false,
107
+ routingIdPrefix: String(source.routingIdPrefix == null ? DEFAULTS.routingIdPrefix : source.routingIdPrefix),
108
+ pollIntervalMs: numberInRange(source.pollIntervalMs, DEFAULTS.pollIntervalMs, 250, 60000),
109
+ requestCheckDelaysMs: Array.isArray(source.requestCheckDelaysMs)
110
+ ? source.requestCheckDelaysMs
111
+ .map((n) => Number(n))
112
+ .filter((n) => Number.isFinite(n) && n >= 0)
113
+ .slice(0, 8)
114
+ : [...DEFAULTS.requestCheckDelaysMs],
115
+ biliSessionsDir: source.biliSessionsDir ? expandHome(source.biliSessionsDir) : null,
116
+ biliProtocol: String(source.biliProtocol == null ? DEFAULTS.biliProtocol : source.biliProtocol),
117
+ biliUpstreamHost: String(source.biliUpstreamHost == null ? DEFAULTS.biliUpstreamHost : source.biliUpstreamHost),
118
+ statePath: source.statePath ? expandHome(source.statePath) : defaultStatePath(),
119
+ dryRun: source.dryRun === true,
120
+ debug: source.debug === true,
121
+ overrideExistingHeader: source.overrideExistingHeader !== false,
122
+ maxSamples: Math.round(numberInRange(source.maxSamples, DEFAULTS.maxSamples, 1, 500)),
123
+ sessionActiveMs: numberInRange(source.sessionActiveMs, DEFAULTS.sessionActiveMs, 1000, 86400000),
124
+ minRotationGapMs: numberInRange(source.minRotationGapMs, DEFAULTS.minRotationGapMs, 0, 86400000),
125
+ };
126
+ }
127
+
128
+ export function isDeepSeekModel(model) {
129
+ return String(model == null ? '' : model).trim().toLowerCase().startsWith(TARGET_MODEL_PREFIX);
130
+ }
131
+
132
+ export function isTargetRequest(options, config) {
133
+ if (!options || typeof options !== 'object' || !config.enabled) return false;
134
+ const provider = String(options.provider == null ? '' : options.provider);
135
+ if (!config.providers.includes(provider)) return false;
136
+ const model = String(options.model == null ? '' : options.model).trim();
137
+ if (!isDeepSeekModel(model)) return false;
138
+ if (config.models.length > 0 && !config.models.includes(model)) return false;
139
+ return true;
140
+ }
141
+
142
+ /**
143
+ * Join the text blocks of a harness message, mirroring pi-ai's flattenText.
144
+ * System messages are text-only, but tolerate the string form for tests and
145
+ * future adapters.
146
+ */
147
+ function flattenSystemMessage(message) {
148
+ if (!message || typeof message !== 'object') return '';
149
+ if (typeof message.content === 'string') return message.content;
150
+ if (!Array.isArray(message.content)) return '';
151
+ return message.content
152
+ .filter((block) => block && block.type === 'text' && typeof block.text === 'string')
153
+ .map((block) => block.text)
154
+ .join('');
155
+ }
156
+
157
+ /**
158
+ * Reproduce the initial system prompt that pi-ai assembles for a request:
159
+ * options.system followed by leading role=system history messages, joined by
160
+ * a blank line. Developer messages emitted mid-conversation are not part of
161
+ * this initial prompt and are intentionally ignored.
162
+ */
163
+ export function systemPromptText(options) {
164
+ if (!options || typeof options !== 'object') return '';
165
+ const parts = [options.system];
166
+ const messages = Array.isArray(options.messages) ? options.messages : [];
167
+ let index = 0;
168
+ while (index < messages.length && messages[index] && messages[index].role === 'system') {
169
+ parts.push(flattenSystemMessage(messages[index]));
170
+ index += 1;
171
+ }
172
+ return parts.filter(Boolean).join('\n\n');
173
+ }
174
+
175
+ /** Match billion-context's system prompt fingerprint: sha256(JSON.stringify(text)).slice(0,16). */
176
+ export function systemPromptFingerprint(text) {
177
+ return createHash('sha256').update(JSON.stringify(String(text == null ? '' : text))).digest('hex').slice(0, 16);
178
+ }
179
+
180
+ /**
181
+ * A system-prompt rewrite guarantees a cold prefix, so the previous fold's
182
+ * cache-hit evidence is not meaningful. Rotation still requires slow samples.
183
+ */
184
+ export function decideSystemRewriteRotation(samples, config) {
185
+ const performance = performanceCondition(samples || [], config);
186
+ if (!performance.bad) {
187
+ return { rotate: false, reason: performance.reason, cache: null, performance };
188
+ }
189
+ return { rotate: true, reason: 'system-rewrite', cache: null, performance };
190
+ }
191
+
192
+ export function newRoutingId(prefix = 'ses_') {
193
+ return String(prefix) + randomBytes(12).toString('hex');
194
+ }
195
+
196
+ export function biliSessionKey(sessionId) {
197
+ return createHash('sha256').update(String(sessionId)).digest('hex').slice(0, 24);
198
+ }
199
+
200
+ export function biliSessionsRoot(config, env = process.env) {
201
+ if (config && config.biliSessionsDir) return config.biliSessionsDir;
202
+ if (env && typeof env.BILI_SESSIONS_DIR === 'string' && env.BILI_SESSIONS_DIR.trim() !== '') {
203
+ return path.resolve(env.BILI_SESSIONS_DIR);
204
+ }
205
+ const xdg = env && typeof env.XDG_DATA_HOME === 'string' ? env.XDG_DATA_HOME.trim() : '';
206
+ return path.join(xdg !== '' ? path.resolve(xdg) : path.join(os.homedir(), '.local', 'share'), 'billion-context', 'sessions');
207
+ }
208
+
209
+ export function sanitizeHostLabel(host) {
210
+ let value = String(host == null ? '' : host).trim();
211
+ if (value === '') return '';
212
+ try {
213
+ if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(value)) value = new URL('https://' + value).hostname;
214
+ else value = new URL(value).hostname;
215
+ } catch {
216
+ // Keep the raw value and sanitize below.
217
+ }
218
+ const cleaned = value.replace(/[^a-zA-Z0-9.-]/g, '-').slice(0, 48);
219
+ return cleaned === '' ? 'unknown' : cleaned;
220
+ }
221
+
222
+ export function biliStatePathCandidates(sessionId, config) {
223
+ const root = biliSessionsRoot(config);
224
+ const key = biliSessionKey(sessionId);
225
+ const proto = String(config.biliProtocol || 'openai');
226
+ const host = sanitizeHostLabel(config.biliUpstreamHost || 'opencode.ai');
227
+ const out = [path.join(root, proto, host + '_' + key + '.json')];
228
+ // Older billion-context builds used the raw origin label or an unknown bucket.
229
+ if (host !== 'unknown') out.push(path.join(root, proto, 'unknown_' + key + '.json'));
230
+ out.push(path.join(root, proto + '_unknown', host + '_' + key + '.json'));
231
+ return out;
232
+ }
233
+
234
+ export function normalizeFolds(rawFolds) {
235
+ if (!Array.isArray(rawFolds)) return [];
236
+ const bySeq = new Map();
237
+ rawFolds.forEach((entry, index) => {
238
+ if (!entry || typeof entry !== 'object') return;
239
+ const seqNum = Number(entry.seq);
240
+ const seq = Number.isFinite(seqNum) && seqNum > 0 ? seqNum : index + 1;
241
+ const hitNum = entry.hPct == null ? null : Number(entry.hPct);
242
+ const atNum = Number(entry.at);
243
+ bySeq.set(seq, {
244
+ seq,
245
+ at: Number.isFinite(atNum) ? atNum : 0,
246
+ hPct: hitNum != null && Number.isFinite(hitNum) ? hitNum : null,
247
+ bid: entry.bid == null ? null : String(entry.bid),
248
+ });
249
+ });
250
+ return [...bySeq.values()].sort((a, b) => a.seq - b.seq);
251
+ }
252
+
253
+ export function extractFolds(stateJson) {
254
+ const root = stateJson && typeof stateJson === 'object' && stateJson.payload ? stateJson.payload : stateJson;
255
+ const ledger = root && root.metadata ? root.metadata.cacheLedger : null;
256
+ return normalizeFolds(ledger ? ledger.folds : []);
257
+ }
258
+
259
+ export function maxFoldSeq(folds) {
260
+ let max = 0;
261
+ for (const fold of folds) if (fold.seq > max) max = fold.seq;
262
+ return max;
263
+ }
264
+
265
+ export function cacheCondition(folds, config) {
266
+ const windowSize = Math.max(1, Math.round(Number(config.foldsWindow) || 1));
267
+ const threshold = Number(config.cacheHitThresholdPct);
268
+ const sorted = normalizeFolds(folds);
269
+ if (sorted.length < windowSize) {
270
+ return {
271
+ bad: false,
272
+ reason: 'not-enough-folds',
273
+ thresholdPct: threshold,
274
+ recent: sorted.slice(-windowSize),
275
+ };
276
+ }
277
+ const recent = sorted.slice(-windowSize);
278
+ const bad = recent.every((fold) => fold.hPct != null && Number.isFinite(fold.hPct) && fold.hPct <= threshold);
279
+ return {
280
+ bad,
281
+ reason: bad ? 'low-cache-hit' : 'cache-hit-ok',
282
+ thresholdPct: threshold,
283
+ recent,
284
+ };
285
+ }
286
+
287
+ function validSample(sample) {
288
+ if (!sample || typeof sample !== 'object') return false;
289
+ return Number.isFinite(Number(sample.ttftMs)) && Number(sample.ttftMs) >= 0
290
+ && Number.isFinite(Number(sample.tps)) && Number(sample.tps) > 0;
291
+ }
292
+
293
+ export function summarizeSamples(samples, sampleSize) {
294
+ const list = Array.isArray(samples) ? samples.filter(validSample) : [];
295
+ const limit = Math.max(1, Math.round(Number(sampleSize) || DEFAULTS.sampleSize));
296
+ const recent = list.slice(-limit);
297
+ let ttftTotal = 0;
298
+ let tpsTotal = 0;
299
+ for (const sample of recent) {
300
+ ttftTotal += Number(sample.ttftMs);
301
+ tpsTotal += Number(sample.tps);
302
+ }
303
+ return {
304
+ count: recent.length,
305
+ avgTtftMs: recent.length > 0 ? ttftTotal / recent.length : null,
306
+ avgTps: recent.length > 0 ? tpsTotal / recent.length : null,
307
+ last: recent.length > 0 ? recent[recent.length - 1] : null,
308
+ };
309
+ }
310
+
311
+ export function performanceCondition(samples, config) {
312
+ const summary = summarizeSamples(samples, config.sampleSize);
313
+ if (summary.count === 0) {
314
+ return { bad: false, reason: 'no-samples', ...summary };
315
+ }
316
+ if (config.requireFullSamples && summary.count < config.sampleSize) {
317
+ return { bad: false, reason: 'not-enough-samples', ...summary };
318
+ }
319
+ const ttftBad = summary.avgTtftMs >= config.ttftThresholdMs;
320
+ const tpsBad = summary.avgTps <= config.throughputThresholdTps;
321
+ return {
322
+ bad: ttftBad || tpsBad,
323
+ reason: ttftBad ? 'ttft-high' : tpsBad ? 'throughput-low' : 'performance-ok',
324
+ ttftBad,
325
+ tpsBad,
326
+ ...summary,
327
+ };
328
+ }
329
+
330
+ export function decideRotation(options) {
331
+ const config = options.config;
332
+ const cache = cacheCondition(options.previousFolds || [], config);
333
+ if (!cache.bad) {
334
+ return { rotate: false, reason: cache.reason, cache, performance: null };
335
+ }
336
+ const performance = performanceCondition(options.samples || [], config);
337
+ if (!performance.bad) {
338
+ return { rotate: false, reason: performance.reason, cache, performance };
339
+ }
340
+ return { rotate: true, reason: 'cache-and-performance', cache, performance };
341
+ }
342
+
343
+ export function round1(value) {
344
+ return Math.round(Number(value) * 10) / 10;
345
+ }