@openbmb/clawxrouter 1.0.4

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.
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Synchronous wrapper around the async LLM desensitization function.
3
+ *
4
+ * Uses `synckit` (Worker thread + Atomics.wait) to run `desensitizeWithLocalModel`
5
+ * synchronously so that the `tool_result_persist` hook (sync-only) can desensitize
6
+ * S2 tool results before they enter the persisted transcript.
7
+ *
8
+ * Timeout (30s) gracefully falls back to a failed result, leaving the caller
9
+ * to decide whether to pass content through or apply regex-only redaction.
10
+ */
11
+
12
+ import { createSyncFn } from "synckit";
13
+ import { fileURLToPath } from "node:url";
14
+ import type { PrivacyConfig } from "./types.js";
15
+
16
+ export type SyncDesensitizeResult = {
17
+ desensitized: string;
18
+ wasModelUsed: boolean;
19
+ failed?: boolean;
20
+ };
21
+
22
+ const workerPath = fileURLToPath(new URL("./llm-desensitize-worker.ts", import.meta.url));
23
+ const loaderPath = fileURLToPath(new URL("./worker-loader.mjs", import.meta.url));
24
+
25
+ let _syncDesensitize: ((content: string, config: PrivacyConfig, sessionKey?: string) => SyncDesensitizeResult) | null = null;
26
+
27
+ function getSyncDesensitize() {
28
+ if (!_syncDesensitize) {
29
+ _syncDesensitize = createSyncFn<(content: string, config: PrivacyConfig, sessionKey?: string) => SyncDesensitizeResult>(
30
+ workerPath,
31
+ { timeout: 30_000, execArgv: ["--import", loaderPath] },
32
+ );
33
+ }
34
+ return _syncDesensitize;
35
+ }
36
+
37
+ export function syncDesensitizeWithLocalModel(
38
+ content: string,
39
+ config: PrivacyConfig,
40
+ sessionKey?: string,
41
+ ): SyncDesensitizeResult {
42
+ try {
43
+ return getSyncDesensitize()(content, config, sessionKey);
44
+ } catch (err) {
45
+ console.warn("[ClawXrouter] syncDesensitize fallback to failed:", (err as Error)?.message?.slice(0, 120));
46
+ return { desensitized: content, wasModelUsed: false, failed: true };
47
+ }
48
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Synchronous wrapper around the async LLM detection function.
3
+ *
4
+ * Uses `synckit` (Worker thread + Atomics.wait) to run `detectByLocalModel`
5
+ * synchronously so that the `tool_result_persist` hook can consume its result
6
+ * before returning — enabling LLM-quality detection that actually modifies
7
+ * the persisted transcript.
8
+ *
9
+ * Timeout gracefully falls back to S1 (safe).
10
+ */
11
+
12
+ import { createSyncFn } from "synckit";
13
+ import { fileURLToPath } from "node:url";
14
+ import type { DetectionContext, DetectionResult, PrivacyConfig } from "./types.js";
15
+
16
+ const FALLBACK_S1: DetectionResult = {
17
+ level: "S1",
18
+ levelNumeric: 1,
19
+ reason: "LLM sync detection unavailable",
20
+ detectorType: "localModelDetector",
21
+ confidence: 0,
22
+ };
23
+
24
+ const workerPath = fileURLToPath(new URL("./llm-detect-worker.ts", import.meta.url));
25
+ const loaderPath = fileURLToPath(new URL("./worker-loader.mjs", import.meta.url));
26
+
27
+ let _syncDetect: ((context: DetectionContext, config: PrivacyConfig) => DetectionResult) | null = null;
28
+
29
+ function getSyncDetect() {
30
+ if (!_syncDetect) {
31
+ _syncDetect = createSyncFn<(context: DetectionContext, config: PrivacyConfig) => DetectionResult>(
32
+ workerPath,
33
+ { timeout: 20_000, execArgv: ["--import", loaderPath] },
34
+ );
35
+ }
36
+ return _syncDetect;
37
+ }
38
+
39
+ export function syncDetectByLocalModel(
40
+ context: DetectionContext,
41
+ config: PrivacyConfig,
42
+ ): DetectionResult {
43
+ try {
44
+ return getSyncDetect()(context, config);
45
+ } catch (err) {
46
+ console.warn("[ClawXrouter] syncDetect fallback to S1:", (err as Error)?.message?.slice(0, 120));
47
+ return FALLBACK_S1;
48
+ }
49
+ }
@@ -0,0 +1,358 @@
1
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
2
+ import { dirname } from "node:path";
3
+ import { getSessionHighestLevel, getSessionRouteLevel, getLoopMeta } from "./session-state.js";
4
+ import { getLiveConfig } from "./live-config.js";
5
+
6
+ // ── Types ──
7
+
8
+ export type RouteCategory = "cloud" | "local" | "proxy";
9
+
10
+ /** Distinguishes router overhead (detection/classification) from actual task execution. */
11
+ export type TokenSource = "router" | "task";
12
+
13
+ export type TokenBucket = {
14
+ inputTokens: number;
15
+ outputTokens: number;
16
+ cacheReadTokens: number;
17
+ totalTokens: number;
18
+ requestCount: number;
19
+ estimatedCost: number;
20
+ };
21
+
22
+ export type SourceBuckets = Record<TokenSource, TokenBucket>;
23
+
24
+ export type HourlyBucket = {
25
+ hour: string;
26
+ cloud: TokenBucket;
27
+ local: TokenBucket;
28
+ proxy: TokenBucket;
29
+ bySource: SourceBuckets;
30
+ };
31
+
32
+ export type SessionTokenStats = {
33
+ sessionKey: string;
34
+ highestLevel: "S1" | "S2" | "S3";
35
+ cloud: TokenBucket;
36
+ local: TokenBucket;
37
+ proxy: TokenBucket;
38
+ bySource: SourceBuckets;
39
+ firstSeenAt: number;
40
+ lastActiveAt: number;
41
+ loopId?: string;
42
+ userMessagePreview?: string;
43
+ };
44
+
45
+ export type TokenStatsData = {
46
+ lifetime: Record<RouteCategory, TokenBucket>;
47
+ bySource: SourceBuckets;
48
+ hourly: HourlyBucket[];
49
+ sessions: Record<string, SessionTokenStats>;
50
+ startedAt: number;
51
+ lastUpdatedAt: number;
52
+ };
53
+
54
+ export type UsageEvent = {
55
+ sessionKey: string;
56
+ provider: string;
57
+ model: string;
58
+ /** "router" for pipeline overhead (judge, detection, PII extraction), "task" for actual request. */
59
+ source?: TokenSource;
60
+ loopId?: string;
61
+ usage?: {
62
+ input?: number;
63
+ output?: number;
64
+ cacheRead?: number;
65
+ cacheWrite?: number;
66
+ total?: number;
67
+ };
68
+ };
69
+
70
+ // ── Token update listeners (used by SSE in the dashboard) ──
71
+
72
+ export type TokenUpdateEvent = {
73
+ sessionKey: string;
74
+ loopId?: string;
75
+ stats: SessionTokenStats;
76
+ };
77
+ type TokenUpdateListener = (event: TokenUpdateEvent) => void;
78
+ const tokenUpdateListeners = new Set<TokenUpdateListener>();
79
+
80
+ export function onTokenUpdate(fn: TokenUpdateListener): () => void {
81
+ tokenUpdateListeners.add(fn);
82
+ return () => { tokenUpdateListeners.delete(fn); };
83
+ }
84
+
85
+ // ── Helpers ──
86
+
87
+ const MAX_HOURLY_BUCKETS = 72;
88
+ const MAX_SESSIONS = 200;
89
+
90
+ function emptyBucket(): TokenBucket {
91
+ return { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, totalTokens: 0, requestCount: 0, estimatedCost: 0 };
92
+ }
93
+
94
+ function emptySourceBuckets(): SourceBuckets {
95
+ return { router: emptyBucket(), task: emptyBucket() };
96
+ }
97
+
98
+ function currentHourKey(): string {
99
+ return new Date().toISOString().slice(0, 13);
100
+ }
101
+
102
+ function emptyStats(): TokenStatsData {
103
+ return {
104
+ lifetime: { cloud: emptyBucket(), local: emptyBucket(), proxy: emptyBucket() },
105
+ bySource: emptySourceBuckets(),
106
+ hourly: [],
107
+ sessions: {},
108
+ startedAt: Date.now(),
109
+ lastUpdatedAt: Date.now(),
110
+ };
111
+ }
112
+
113
+ function addToBucket(bucket: TokenBucket, usage: UsageEvent["usage"], cost = 0): void {
114
+ const input = usage?.input ?? 0;
115
+ const output = usage?.output ?? 0;
116
+ const cacheRead = usage?.cacheRead ?? 0;
117
+ bucket.inputTokens += input;
118
+ bucket.outputTokens += output;
119
+ bucket.cacheReadTokens += cacheRead;
120
+ bucket.totalTokens += usage?.total ?? (input + output);
121
+ bucket.requestCount += 1;
122
+ bucket.estimatedCost += cost;
123
+ }
124
+
125
+ /** Look up pricing for a model: exact match, then substring match, then default. */
126
+ export function lookupPricing(model: string): { inputPer1M: number; outputPer1M: number } {
127
+ const pricing = getLiveConfig().modelPricing;
128
+ if (!pricing) return { inputPer1M: 3, outputPer1M: 15 };
129
+
130
+ if (pricing[model]) {
131
+ return { inputPer1M: pricing[model].inputPer1M ?? 3, outputPer1M: pricing[model].outputPer1M ?? 15 };
132
+ }
133
+
134
+ const lowerModel = model.toLowerCase();
135
+ for (const [key, val] of Object.entries(pricing)) {
136
+ if (lowerModel.includes(key.toLowerCase())) {
137
+ return { inputPer1M: val.inputPer1M ?? 3, outputPer1M: val.outputPer1M ?? 15 };
138
+ }
139
+ }
140
+
141
+ return { inputPer1M: 3, outputPer1M: 15 };
142
+ }
143
+
144
+ function calculateCost(model: string, usage: UsageEvent["usage"]): number {
145
+ const input = usage?.input ?? 0;
146
+ const output = usage?.output ?? 0;
147
+ const p = lookupPricing(model);
148
+ return (input * p.inputPer1M + output * p.outputPer1M) / 1_000_000;
149
+ }
150
+
151
+ /**
152
+ * Classify a usage event for cost/bucket assignment.
153
+ * Uses `routeLevel` (set at before_model_resolve time) so that
154
+ * post-routing S3 escalations (e.g. from tool_result_persist) don't
155
+ * retroactively zero-out cost for cloud calls already made under S1.
156
+ */
157
+ function classifyEvent(event: UsageEvent): RouteCategory {
158
+ if (event.source === "router") return "local";
159
+ if (event.provider === "edge" || event.provider === "local") return "local";
160
+ const level = getSessionRouteLevel(event.sessionKey);
161
+ if (level === "S3") return "local";
162
+ if (level === "S2" && getLiveConfig().s2Policy !== "local") return "proxy";
163
+ return "cloud";
164
+ }
165
+
166
+ // ── Collector ──
167
+
168
+ export class TokenStatsCollector {
169
+ private data: TokenStatsData;
170
+ private filePath: string;
171
+ private flushTimer: ReturnType<typeof setInterval> | null = null;
172
+ private dirty = false;
173
+
174
+ constructor(filePath: string) {
175
+ this.filePath = filePath;
176
+ this.data = emptyStats();
177
+ }
178
+
179
+ /** Load persisted stats from disk. Merges with empty defaults for missing fields. */
180
+ async load(): Promise<void> {
181
+ try {
182
+ const raw = await readFile(this.filePath, "utf-8");
183
+ const parsed = JSON.parse(raw) as Partial<TokenStatsData>;
184
+ const rawSessions = (parsed.sessions && typeof parsed.sessions === "object")
185
+ ? parsed.sessions as Record<string, SessionTokenStats>
186
+ : {};
187
+ const parsedBySource = parsed.bySource as Partial<SourceBuckets> | undefined;
188
+ this.data = {
189
+ lifetime: {
190
+ cloud: { ...emptyBucket(), ...parsed.lifetime?.cloud },
191
+ local: { ...emptyBucket(), ...parsed.lifetime?.local },
192
+ proxy: { ...emptyBucket(), ...parsed.lifetime?.proxy },
193
+ },
194
+ bySource: {
195
+ router: { ...emptyBucket(), ...parsedBySource?.router },
196
+ task: { ...emptyBucket(), ...parsedBySource?.task },
197
+ },
198
+ hourly: Array.isArray(parsed.hourly) ? parsed.hourly : [],
199
+ sessions: rawSessions,
200
+ startedAt: parsed.startedAt ?? Date.now(),
201
+ lastUpdatedAt: parsed.lastUpdatedAt ?? Date.now(),
202
+ };
203
+ } catch {
204
+ this.data = emptyStats();
205
+ }
206
+ }
207
+
208
+ /** Start periodic flush (every 5 minutes). */
209
+ startAutoFlush(): void {
210
+ if (this.flushTimer) return;
211
+ this.flushTimer = setInterval(() => {
212
+ if (this.dirty) this.flush().catch(() => {});
213
+ }, 300_000);
214
+ if (this.flushTimer && typeof this.flushTimer === "object" && "unref" in this.flushTimer) {
215
+ (this.flushTimer as NodeJS.Timeout).unref();
216
+ }
217
+ }
218
+
219
+ /** Stop periodic flush. */
220
+ stopAutoFlush(): void {
221
+ if (this.flushTimer) {
222
+ clearInterval(this.flushTimer);
223
+ this.flushTimer = null;
224
+ }
225
+ }
226
+
227
+ /** Record a usage event from llm_output hook or router overhead. */
228
+ record(event: UsageEvent): void {
229
+ const category = classifyEvent(event);
230
+ const source: TokenSource = event.source ?? "task";
231
+ const now = Date.now();
232
+
233
+ const cost = category !== "local"
234
+ ? calculateCost(event.model, event.usage)
235
+ : 0;
236
+
237
+ addToBucket(this.data.lifetime[category], event.usage, cost);
238
+ addToBucket(this.data.bySource[source], event.usage, cost);
239
+
240
+ const hourKey = currentHourKey();
241
+ let hourly = this.data.hourly.find((h) => h.hour === hourKey);
242
+ if (!hourly) {
243
+ hourly = { hour: hourKey, cloud: emptyBucket(), local: emptyBucket(), proxy: emptyBucket(), bySource: emptySourceBuckets() };
244
+ this.data.hourly.push(hourly);
245
+ if (this.data.hourly.length > MAX_HOURLY_BUCKETS) {
246
+ this.data.hourly = this.data.hourly.slice(-MAX_HOURLY_BUCKETS);
247
+ }
248
+ }
249
+ if (!hourly.bySource) hourly.bySource = emptySourceBuckets();
250
+ addToBucket(hourly[category], event.usage, cost);
251
+ addToBucket(hourly.bySource[source], event.usage, cost);
252
+
253
+ // Per-loop tracking (keyed by sessionKey:loopId)
254
+ const sk = event.sessionKey;
255
+ const lid = event.loopId;
256
+ if (sk && lid) {
257
+ const compoundKey = `${sk}::${lid}`;
258
+ let sess = this.data.sessions[compoundKey];
259
+ if (!sess) {
260
+ const meta = getLoopMeta(lid);
261
+ sess = {
262
+ sessionKey: sk,
263
+ highestLevel: getSessionHighestLevel(sk),
264
+ cloud: emptyBucket(),
265
+ local: emptyBucket(),
266
+ proxy: emptyBucket(),
267
+ bySource: emptySourceBuckets(),
268
+ firstSeenAt: now,
269
+ lastActiveAt: now,
270
+ loopId: lid,
271
+ userMessagePreview: meta?.userMessagePreview ?? "",
272
+ };
273
+ this.data.sessions[compoundKey] = sess;
274
+ }
275
+ if (!sess.bySource) sess.bySource = emptySourceBuckets();
276
+ const loopMeta = getLoopMeta(lid);
277
+ sess.highestLevel = loopMeta?.highestLevel ?? getSessionHighestLevel(sk);
278
+ sess.lastActiveAt = now;
279
+ addToBucket(sess[category], event.usage, cost);
280
+ addToBucket(sess.bySource[source], event.usage, cost);
281
+ this.evictOldSessions();
282
+
283
+ for (const fn of tokenUpdateListeners) {
284
+ try { fn({ sessionKey: sk, loopId: lid, stats: sess }); } catch { /* ignore */ }
285
+ }
286
+ }
287
+
288
+ this.data.lastUpdatedAt = now;
289
+ this.dirty = true;
290
+ }
291
+
292
+ private evictOldSessions(): void {
293
+ const keys = Object.keys(this.data.sessions);
294
+ if (keys.length <= MAX_SESSIONS) return;
295
+ const sorted = keys.sort(
296
+ (a, b) => this.data.sessions[a].lastActiveAt - this.data.sessions[b].lastActiveAt,
297
+ );
298
+ const toRemove = sorted.slice(0, keys.length - MAX_SESSIONS);
299
+ for (const k of toRemove) delete this.data.sessions[k];
300
+ }
301
+
302
+ /** Get snapshot of current stats. */
303
+ getStats(): TokenStatsData {
304
+ return this.data;
305
+ }
306
+
307
+ /** Get summary for API response. */
308
+ getSummary(): { lifetime: TokenStatsData["lifetime"]; bySource: SourceBuckets; lastUpdatedAt: number; startedAt: number } {
309
+ return {
310
+ lifetime: this.data.lifetime,
311
+ bySource: this.data.bySource,
312
+ lastUpdatedAt: this.data.lastUpdatedAt,
313
+ startedAt: this.data.startedAt,
314
+ };
315
+ }
316
+
317
+ /** Get hourly data for API response. */
318
+ getHourly(): HourlyBucket[] {
319
+ return this.data.hourly;
320
+ }
321
+
322
+ /** Get per-session stats sorted by lastActiveAt descending. */
323
+ getSessionStats(): SessionTokenStats[] {
324
+ return Object.values(this.data.sessions).sort(
325
+ (a, b) => b.lastActiveAt - a.lastActiveAt,
326
+ );
327
+ }
328
+
329
+ /** Reset all stats to empty and flush to disk. */
330
+ async reset(): Promise<void> {
331
+ this.data = emptyStats();
332
+ this.dirty = true;
333
+ await this.flush();
334
+ }
335
+
336
+ /** Flush to disk. */
337
+ async flush(): Promise<void> {
338
+ try {
339
+ await mkdir(dirname(this.filePath), { recursive: true });
340
+ await writeFile(this.filePath, JSON.stringify(this.data, null, 2), "utf-8");
341
+ this.dirty = false;
342
+ } catch {
343
+ // Non-critical — stats will be retried on next flush
344
+ }
345
+ }
346
+ }
347
+
348
+ // ── Singleton ──
349
+
350
+ let globalCollector: TokenStatsCollector | null = null;
351
+
352
+ export function setGlobalCollector(collector: TokenStatsCollector): void {
353
+ globalCollector = collector;
354
+ }
355
+
356
+ export function getGlobalCollector(): TokenStatsCollector | null {
357
+ return globalCollector;
358
+ }