@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.
- package/config.example.json +204 -0
- package/index.ts +398 -0
- package/openclaw.plugin.json +97 -0
- package/package.json +48 -0
- package/prompts/detection-system.md +50 -0
- package/prompts/token-saver-judge.md +25 -0
- package/src/config-schema.ts +210 -0
- package/src/dashboard-config-io.ts +25 -0
- package/src/detector.ts +230 -0
- package/src/guard-agent.ts +86 -0
- package/src/hooks.ts +1428 -0
- package/src/live-config.ts +75 -0
- package/src/llm-desensitize-worker.ts +7 -0
- package/src/llm-detect-worker.ts +7 -0
- package/src/local-model.ts +723 -0
- package/src/memory-isolation.ts +403 -0
- package/src/privacy-proxy.ts +683 -0
- package/src/prompt-loader.ts +101 -0
- package/src/provider.ts +268 -0
- package/src/router-pipeline.ts +380 -0
- package/src/routers/configurable.ts +208 -0
- package/src/routers/privacy.ts +102 -0
- package/src/routers/token-saver.ts +273 -0
- package/src/rules.ts +320 -0
- package/src/session-manager.ts +377 -0
- package/src/session-state.ts +471 -0
- package/src/stats-dashboard.ts +3402 -0
- package/src/sync-desensitize.ts +48 -0
- package/src/sync-detect.ts +49 -0
- package/src/token-stats.ts +358 -0
- package/src/types.ts +269 -0
- package/src/utils.ts +283 -0
- package/src/worker-loader.mjs +25 -0
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Checkpoint,
|
|
3
|
+
DetectionContext,
|
|
4
|
+
ClawXrouterRouter,
|
|
5
|
+
PipelineConfig,
|
|
6
|
+
RouterDecision,
|
|
7
|
+
RouterRegistration,
|
|
8
|
+
} from "./types.js";
|
|
9
|
+
import { maxLevel } from "./types.js";
|
|
10
|
+
|
|
11
|
+
export class RouterPipeline {
|
|
12
|
+
private routers = new Map<string, ClawXrouterRouter>();
|
|
13
|
+
private pipelineConfig: PipelineConfig = {};
|
|
14
|
+
private routerConfigs = new Map<string, RouterRegistration>();
|
|
15
|
+
private logger: { info: (m: string) => void; warn: (m: string) => void; error: (m: string) => void };
|
|
16
|
+
|
|
17
|
+
constructor(logger?: { info: (m: string) => void; warn: (m: string) => void; error: (m: string) => void }) {
|
|
18
|
+
this.logger = logger ?? {
|
|
19
|
+
info: (m: string) => console.log(m),
|
|
20
|
+
warn: (m: string) => console.warn(m),
|
|
21
|
+
error: (m: string) => console.error(m),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Register a router instance. Overwrites if same id exists.
|
|
27
|
+
*/
|
|
28
|
+
register(router: ClawXrouterRouter, registration?: RouterRegistration): void {
|
|
29
|
+
this.routers.set(router.id, router);
|
|
30
|
+
if (registration) {
|
|
31
|
+
this.routerConfigs.set(router.id, registration);
|
|
32
|
+
}
|
|
33
|
+
this.logger.info(`[RouterPipeline] Registered router: ${router.id}`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Load a custom router from a module path.
|
|
38
|
+
*/
|
|
39
|
+
async loadCustomRouter(id: string, modulePath: string, registration?: RouterRegistration): Promise<void> {
|
|
40
|
+
try {
|
|
41
|
+
const mod = await import(modulePath);
|
|
42
|
+
const router: ClawXrouterRouter = mod.default ?? mod;
|
|
43
|
+
if (!router.detect || typeof router.detect !== "function") {
|
|
44
|
+
this.logger.error(`[RouterPipeline] Custom router "${id}" from ${modulePath} does not export a valid detect() function`);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
router.id = id;
|
|
48
|
+
this.register(router, registration);
|
|
49
|
+
} catch (err) {
|
|
50
|
+
this.logger.error(`[RouterPipeline] Failed to load custom router "${id}" from ${modulePath}: ${String(err)}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Configure the pipeline from the plugin config.
|
|
56
|
+
*/
|
|
57
|
+
configure(config: {
|
|
58
|
+
routers?: Record<string, RouterRegistration | undefined>;
|
|
59
|
+
pipeline?: PipelineConfig;
|
|
60
|
+
}): void {
|
|
61
|
+
if (config.routers) {
|
|
62
|
+
for (const [id, reg] of Object.entries(config.routers)) {
|
|
63
|
+
if (reg) this.routerConfigs.set(id, reg);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (config.pipeline) {
|
|
67
|
+
this.pipelineConfig = config.pipeline;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Load all custom routers declared in config.
|
|
73
|
+
*/
|
|
74
|
+
async loadCustomRouters(): Promise<void> {
|
|
75
|
+
for (const [id, reg] of this.routerConfigs) {
|
|
76
|
+
if (reg.type === "custom" && reg.module && !this.routers.has(id)) {
|
|
77
|
+
await this.loadCustomRouter(id, reg.module, reg);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Get the ordered list of router IDs for a checkpoint.
|
|
84
|
+
* Falls back to running all enabled routers if pipeline config is not set.
|
|
85
|
+
*/
|
|
86
|
+
getRoutersForCheckpoint(checkpoint: Checkpoint): string[] {
|
|
87
|
+
const configured = this.pipelineConfig[checkpoint];
|
|
88
|
+
if (configured && configured.length > 0) {
|
|
89
|
+
return configured;
|
|
90
|
+
}
|
|
91
|
+
// Fallback: all registered routers in registration order
|
|
92
|
+
return [...this.routers.keys()];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Check if a router is enabled via config.
|
|
97
|
+
*/
|
|
98
|
+
private isRouterEnabled(id: string): boolean {
|
|
99
|
+
const reg = this.routerConfigs.get(id);
|
|
100
|
+
return reg?.enabled !== false;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Get the configured weight for a router (default 50).
|
|
105
|
+
*/
|
|
106
|
+
getRouterWeight(id: string): number {
|
|
107
|
+
return this.routerConfigs.get(id)?.weight ?? 50;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Run the pipeline for a given checkpoint.
|
|
112
|
+
*
|
|
113
|
+
* Two-phase execution with short-circuit:
|
|
114
|
+
* Phase 1 — run all "fast" routers (weight >= 50) in parallel.
|
|
115
|
+
* If any returns S3 (or S2-local) with a non-passthrough action,
|
|
116
|
+
* skip slow routers.
|
|
117
|
+
* Phase 2 — run remaining "slow" routers (weight < 50) when Phase 1 was
|
|
118
|
+
* all S1, or when S2-proxy is the highest level (so token-saver
|
|
119
|
+
* can still select the best model for the proxied request).
|
|
120
|
+
*
|
|
121
|
+
* This avoids expensive LLM judge calls (token-saver) when rule-based
|
|
122
|
+
* detection already determined the message must stay local (S3 / S2-local),
|
|
123
|
+
* while still allowing cost optimization for S2-proxy requests.
|
|
124
|
+
*/
|
|
125
|
+
async run(
|
|
126
|
+
checkpoint: Checkpoint,
|
|
127
|
+
context: DetectionContext,
|
|
128
|
+
pluginConfig: Record<string, unknown>,
|
|
129
|
+
): Promise<RouterDecision> {
|
|
130
|
+
const routerIds = this.getRoutersForCheckpoint(checkpoint);
|
|
131
|
+
|
|
132
|
+
if (routerIds.length === 0) {
|
|
133
|
+
return { level: "S1", action: "passthrough", reason: "No routers configured" };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
type Entry = { id: string; weight: number; router: ClawXrouterRouter };
|
|
137
|
+
const fast: Entry[] = [];
|
|
138
|
+
const slow: Entry[] = [];
|
|
139
|
+
|
|
140
|
+
for (const id of routerIds) {
|
|
141
|
+
if (!this.isRouterEnabled(id)) continue;
|
|
142
|
+
const router = this.routers.get(id);
|
|
143
|
+
if (!router) {
|
|
144
|
+
this.logger.warn(`[RouterPipeline] Router "${id}" referenced in pipeline but not registered`);
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
const weight = this.getRouterWeight(id);
|
|
148
|
+
(weight >= 50 ? fast : slow).push({ id, weight, router });
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (fast.length === 0 && slow.length === 0) {
|
|
152
|
+
return { level: "S1", action: "passthrough", reason: "No enabled routers" };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Phase 1: fast (high-weight) routers in parallel
|
|
156
|
+
const fastResults = await this.runGroup(fast, context, pluginConfig);
|
|
157
|
+
|
|
158
|
+
for (const r of fastResults) {
|
|
159
|
+
this.logger.info(
|
|
160
|
+
`[RouterPipeline] ${r.decision.routerId}: level=${r.decision.level} action=${r.decision.action ?? "passthrough"} ${r.decision.reason ? `reason="${r.decision.reason}"` : ""} ${r.decision.target ? `target=${r.decision.target.provider}/${r.decision.target.model}` : ""}`.trim(),
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const mustShortCircuit = fastResults.some((r) => {
|
|
165
|
+
if (r.decision.level === "S1" || r.decision.action === "passthrough") return false;
|
|
166
|
+
// S2 via privacy proxy can benefit from Phase 2 (e.g. TokenSaver model selection)
|
|
167
|
+
if (r.decision.level === "S2" && r.decision.target?.provider === "clawxrouter-privacy") return false;
|
|
168
|
+
return true;
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
if (mustShortCircuit || slow.length === 0) {
|
|
172
|
+
if (mustShortCircuit && slow.length > 0) {
|
|
173
|
+
this.logger.info(
|
|
174
|
+
`[ClawXrouter] [${checkpoint}] Short-circuit: skipping ${slow.map((s) => s.id).join(",")}`,
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
const merged = mergeDecisionsWeighted(fastResults);
|
|
178
|
+
this.logFinalDecision(checkpoint, merged);
|
|
179
|
+
return merged;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Phase 2: slow (low-weight) routers — runs when Phase 1 is all-S1 or S2-proxy
|
|
183
|
+
const slowResults = await this.runGroup(slow, context, pluginConfig);
|
|
184
|
+
for (const r of slowResults) {
|
|
185
|
+
this.logger.info(
|
|
186
|
+
`[RouterPipeline] ${r.decision.routerId}: level=${r.decision.level} action=${r.decision.action ?? "passthrough"} ${r.decision.reason ? `reason="${r.decision.reason}"` : ""} ${r.decision.target ? `target=${r.decision.target.provider}/${r.decision.target.model}` : ""}`.trim(),
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
const merged = mergeDecisionsWeighted([...fastResults, ...slowResults]);
|
|
190
|
+
this.logFinalDecision(checkpoint, merged);
|
|
191
|
+
return merged;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
private async runGroup(
|
|
195
|
+
group: Array<{ id: string; weight: number; router: ClawXrouterRouter }>,
|
|
196
|
+
context: DetectionContext,
|
|
197
|
+
pluginConfig: Record<string, unknown>,
|
|
198
|
+
): Promise<WeightedDecision[]> {
|
|
199
|
+
const tasks = group.map(({ id, weight, router }) => ({
|
|
200
|
+
id,
|
|
201
|
+
weight,
|
|
202
|
+
promise: router.detect(context, pluginConfig).then((d) => {
|
|
203
|
+
d.routerId = id;
|
|
204
|
+
return d;
|
|
205
|
+
}),
|
|
206
|
+
}));
|
|
207
|
+
|
|
208
|
+
const settled = await Promise.allSettled(tasks.map((t) => t.promise));
|
|
209
|
+
const results: WeightedDecision[] = [];
|
|
210
|
+
|
|
211
|
+
for (let i = 0; i < settled.length; i++) {
|
|
212
|
+
const result = settled[i];
|
|
213
|
+
const { id, weight } = tasks[i];
|
|
214
|
+
if (result.status === "fulfilled") {
|
|
215
|
+
const d = result.value;
|
|
216
|
+
const reasonStr = d.reason ? ` (${d.reason})` : "";
|
|
217
|
+
const targetStr = d.target ? ` → ${d.target.provider}/${d.target.model}` : "";
|
|
218
|
+
this.logger.info(`[ClawXrouter] [${context.checkpoint}] ${id}: ${d.level} ${d.action ?? "passthrough"}${targetStr}${reasonStr}`);
|
|
219
|
+
results.push({ decision: d, weight });
|
|
220
|
+
} else {
|
|
221
|
+
this.logger.error(`[RouterPipeline] Router "${id}" failed: ${String(result.reason)}`);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return results;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
private logFinalDecision(checkpoint: Checkpoint, d: RouterDecision): void {
|
|
229
|
+
const targetStr = d.target ? ` → ${d.target.provider}/${d.target.model}` : "";
|
|
230
|
+
const reasonStr = d.reason ? ` (${d.reason})` : "";
|
|
231
|
+
const log = d.level === "S1" ? this.logger.info : this.logger.warn;
|
|
232
|
+
log.call(this.logger, `[ClawXrouter] [${checkpoint}] ▶ Final: ${d.level} ${d.action ?? "passthrough"}${targetStr}${reasonStr}`);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Run a single router by ID (for per-router testing).
|
|
237
|
+
*/
|
|
238
|
+
async runSingle(
|
|
239
|
+
id: string,
|
|
240
|
+
context: DetectionContext,
|
|
241
|
+
pluginConfig: Record<string, unknown>,
|
|
242
|
+
): Promise<RouterDecision | null> {
|
|
243
|
+
const router = this.routers.get(id);
|
|
244
|
+
if (!router) return null;
|
|
245
|
+
const decision = await router.detect({ ...context, dryRun: true }, pluginConfig);
|
|
246
|
+
decision.routerId = id;
|
|
247
|
+
return decision;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* List all registered router IDs.
|
|
252
|
+
*/
|
|
253
|
+
listRouters(): string[] {
|
|
254
|
+
return [...this.routers.keys()];
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Check if a router is registered.
|
|
259
|
+
*/
|
|
260
|
+
hasRouter(id: string): boolean {
|
|
261
|
+
return this.routers.has(id);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
type WeightedDecision = { decision: RouterDecision; weight: number };
|
|
266
|
+
|
|
267
|
+
const ACTION_PRIORITY: Record<string, number> = {
|
|
268
|
+
block: 4,
|
|
269
|
+
redirect: 3,
|
|
270
|
+
transform: 2,
|
|
271
|
+
passthrough: 1,
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Merge router decisions using weighted scoring.
|
|
276
|
+
*
|
|
277
|
+
* Strategy:
|
|
278
|
+
* 1. Safety-first: highest sensitivity level always wins (S3 > S2 > S1).
|
|
279
|
+
* 2. Among decisions at the same level, weight breaks ties — the router
|
|
280
|
+
* with the higher weight determines the action/target.
|
|
281
|
+
* 3. If weights are equal, action severity breaks the tie
|
|
282
|
+
* (block > redirect > transform > passthrough).
|
|
283
|
+
* 4. Final confidence is a weighted average.
|
|
284
|
+
*/
|
|
285
|
+
function mergeDecisionsWeighted(items: WeightedDecision[]): RouterDecision {
|
|
286
|
+
if (items.length === 0) {
|
|
287
|
+
return { level: "S1", action: "passthrough", reason: "No decisions" };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (items.length === 1) {
|
|
291
|
+
return items[0].decision;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const levels = items.map((i) => i.decision.level);
|
|
295
|
+
const winningLevel = maxLevel(...levels);
|
|
296
|
+
|
|
297
|
+
const atWinningLevel = items.filter((i) => i.decision.level === winningLevel);
|
|
298
|
+
|
|
299
|
+
atWinningLevel.sort((a, b) => {
|
|
300
|
+
if (b.weight !== a.weight) return b.weight - a.weight;
|
|
301
|
+
return (
|
|
302
|
+
(ACTION_PRIORITY[b.decision.action ?? "passthrough"] ?? 0) -
|
|
303
|
+
(ACTION_PRIORITY[a.decision.action ?? "passthrough"] ?? 0)
|
|
304
|
+
);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
let winner = atWinningLevel[0].decision;
|
|
308
|
+
|
|
309
|
+
// When the winning level is S1 and the highest-weight router says "passthrough"
|
|
310
|
+
// (i.e., it has no concern), but another router wants to "redirect" (e.g.,
|
|
311
|
+
// token-saver wants a specific model), honor the redirect — passthrough at S1
|
|
312
|
+
// means "no opinion", not "I insist on default".
|
|
313
|
+
if (winningLevel === "S1" && (winner.action ?? "passthrough") === "passthrough") {
|
|
314
|
+
const redirectCandidate = atWinningLevel.find(
|
|
315
|
+
(i) => (i.decision.action ?? "passthrough") === "redirect" && i.decision.target,
|
|
316
|
+
);
|
|
317
|
+
if (redirectCandidate) {
|
|
318
|
+
winner = redirectCandidate.decision;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// S2-proxy + token-saver: keep the proxy path but adopt the model selected
|
|
323
|
+
// by a lower-level router (e.g. token-saver's tier-based model).
|
|
324
|
+
// The proxy strips PII before forwarding, so model selection still matters.
|
|
325
|
+
if (
|
|
326
|
+
winningLevel === "S2" &&
|
|
327
|
+
winner.target?.provider === "clawxrouter-privacy" &&
|
|
328
|
+
!winner.target.model
|
|
329
|
+
) {
|
|
330
|
+
const modelHint = items.find(
|
|
331
|
+
(i) =>
|
|
332
|
+
i.decision.level === "S1" &&
|
|
333
|
+
(i.decision.action ?? "passthrough") === "redirect" &&
|
|
334
|
+
i.decision.target?.model,
|
|
335
|
+
);
|
|
336
|
+
if (modelHint) {
|
|
337
|
+
const hintTarget = modelHint.decision.target!;
|
|
338
|
+
winner = {
|
|
339
|
+
...winner,
|
|
340
|
+
target: {
|
|
341
|
+
...winner.target,
|
|
342
|
+
model: hintTarget.model,
|
|
343
|
+
originalProvider: hintTarget.provider !== "clawxrouter-privacy" ? hintTarget.provider : undefined,
|
|
344
|
+
},
|
|
345
|
+
reason: [winner.reason, modelHint.decision.reason].filter(Boolean).join("; "),
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const allReasons = items
|
|
351
|
+
.filter((i) => i.decision.level !== "S1" && i.decision.reason)
|
|
352
|
+
.map((i) => `[${i.decision.routerId ?? "?"}:w${i.weight}] ${i.decision.reason}`);
|
|
353
|
+
|
|
354
|
+
const totalWeight = items.reduce((s, i) => s + i.weight, 0);
|
|
355
|
+
const weightedConfidence =
|
|
356
|
+
totalWeight > 0
|
|
357
|
+
? items.reduce((s, i) => s + (i.decision.confidence ?? 0.5) * i.weight, 0) / totalWeight
|
|
358
|
+
: 0.5;
|
|
359
|
+
|
|
360
|
+
return {
|
|
361
|
+
level: winningLevel,
|
|
362
|
+
action: winner.action ?? "passthrough",
|
|
363
|
+
target: winner.target,
|
|
364
|
+
transformedContent: winner.transformedContent,
|
|
365
|
+
reason: allReasons.length > 0 ? allReasons.join("; ") : winner.reason,
|
|
366
|
+
confidence: weightedConfidence,
|
|
367
|
+
routerId: winner.routerId,
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** Singleton pipeline instance (set during plugin init) */
|
|
372
|
+
let globalPipeline: RouterPipeline | null = null;
|
|
373
|
+
|
|
374
|
+
export function setGlobalPipeline(pipeline: RouterPipeline): void {
|
|
375
|
+
globalPipeline = pipeline;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
export function getGlobalPipeline(): RouterPipeline | null {
|
|
379
|
+
return globalPipeline;
|
|
380
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
DetectionContext,
|
|
3
|
+
ClawXrouterRouter,
|
|
4
|
+
RouterAction,
|
|
5
|
+
RouterDecision,
|
|
6
|
+
SensitivityLevel,
|
|
7
|
+
RouterRegistration,
|
|
8
|
+
} from "../types.js";
|
|
9
|
+
import { maxLevel } from "../types.js";
|
|
10
|
+
import { callChatCompletion } from "../local-model.js";
|
|
11
|
+
import type { PrivacyConfig } from "../types.js";
|
|
12
|
+
import { getGuardAgentConfig } from "../guard-agent.js";
|
|
13
|
+
import { getKeywordRegex } from "../rules.js";
|
|
14
|
+
|
|
15
|
+
export interface ConfigurableRouterOptions {
|
|
16
|
+
keywords?: { S2?: string[]; S3?: string[] };
|
|
17
|
+
patterns?: { S2?: string[]; S3?: string[] };
|
|
18
|
+
prompt?: string;
|
|
19
|
+
action?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function getOptions(
|
|
23
|
+
routerId: string,
|
|
24
|
+
pluginConfig: Record<string, unknown>,
|
|
25
|
+
): ConfigurableRouterOptions {
|
|
26
|
+
const privacy = (pluginConfig?.privacy ?? {}) as Record<string, unknown>;
|
|
27
|
+
const routers = (privacy.routers ?? {}) as Record<string, RouterRegistration>;
|
|
28
|
+
const reg = routers[routerId];
|
|
29
|
+
return (reg?.options ?? {}) as ConfigurableRouterOptions;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function getPrivacyConfig(pluginConfig: Record<string, unknown>): PrivacyConfig {
|
|
33
|
+
return (pluginConfig?.privacy ?? {}) as PrivacyConfig;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function checkKeywords(
|
|
37
|
+
text: string,
|
|
38
|
+
keywords: { S2?: string[]; S3?: string[] },
|
|
39
|
+
): { level: SensitivityLevel; reason?: string } {
|
|
40
|
+
for (const kw of keywords.S3 ?? []) {
|
|
41
|
+
if (getKeywordRegex(kw).test(text)) {
|
|
42
|
+
return { level: "S3", reason: `S3 keyword: ${kw}` };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
for (const kw of keywords.S2 ?? []) {
|
|
46
|
+
if (getKeywordRegex(kw).test(text)) {
|
|
47
|
+
return { level: "S2", reason: `S2 keyword: ${kw}` };
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return { level: "S1" };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function checkPatterns(
|
|
54
|
+
text: string,
|
|
55
|
+
patterns: { S2?: string[]; S3?: string[] },
|
|
56
|
+
): { level: SensitivityLevel; reason?: string } {
|
|
57
|
+
for (const pat of patterns.S3 ?? []) {
|
|
58
|
+
try {
|
|
59
|
+
if (new RegExp(pat, "i").test(text)) {
|
|
60
|
+
return { level: "S3", reason: `S3 pattern: ${pat}` };
|
|
61
|
+
}
|
|
62
|
+
} catch { /* skip invalid regex */ }
|
|
63
|
+
}
|
|
64
|
+
for (const pat of patterns.S2 ?? []) {
|
|
65
|
+
try {
|
|
66
|
+
if (new RegExp(pat, "i").test(text)) {
|
|
67
|
+
return { level: "S2", reason: `S2 pattern: ${pat}` };
|
|
68
|
+
}
|
|
69
|
+
} catch { /* skip invalid regex */ }
|
|
70
|
+
}
|
|
71
|
+
return { level: "S1" };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function classifyWithPrompt(
|
|
75
|
+
message: string,
|
|
76
|
+
systemPrompt: string,
|
|
77
|
+
pluginConfig: Record<string, unknown>,
|
|
78
|
+
): Promise<{ level: SensitivityLevel; reason?: string } | null> {
|
|
79
|
+
const pCfg = getPrivacyConfig(pluginConfig);
|
|
80
|
+
const lm = pCfg.localModel;
|
|
81
|
+
if (!lm?.enabled || !lm.endpoint) return null;
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
const raw = await callChatCompletion(
|
|
85
|
+
lm.endpoint,
|
|
86
|
+
lm.model ?? "",
|
|
87
|
+
[
|
|
88
|
+
{ role: "system", content: systemPrompt },
|
|
89
|
+
{ role: "user", content: message },
|
|
90
|
+
],
|
|
91
|
+
{
|
|
92
|
+
temperature: 0,
|
|
93
|
+
maxTokens: 256,
|
|
94
|
+
apiKey: lm.apiKey,
|
|
95
|
+
providerType: (lm.type ?? "openai-compatible") as "openai-compatible" | "ollama-native" | "custom",
|
|
96
|
+
customModule: lm.module,
|
|
97
|
+
},
|
|
98
|
+
);
|
|
99
|
+
const text = raw.text.trim();
|
|
100
|
+
const jsonMatch = text.match(/\{[\s\S]*?\}/);
|
|
101
|
+
if (!jsonMatch) return null;
|
|
102
|
+
const parsed = JSON.parse(jsonMatch[0]);
|
|
103
|
+
const level = String(parsed.level ?? "S1").toUpperCase();
|
|
104
|
+
if (level === "S2" || level === "S3") {
|
|
105
|
+
return { level: level as SensitivityLevel, reason: parsed.reason ?? "LLM classification" };
|
|
106
|
+
}
|
|
107
|
+
return { level: "S1" };
|
|
108
|
+
} catch {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Resolve the routing target for S2/S3, aligned with the privacy router's
|
|
115
|
+
* target resolution so hooks.ts can route correctly.
|
|
116
|
+
*/
|
|
117
|
+
function resolveTargetForLevel(
|
|
118
|
+
level: SensitivityLevel,
|
|
119
|
+
pluginConfig: Record<string, unknown>,
|
|
120
|
+
): { provider: string; model: string } {
|
|
121
|
+
const pCfg = getPrivacyConfig(pluginConfig);
|
|
122
|
+
if (level === "S3") {
|
|
123
|
+
const guardCfg = getGuardAgentConfig(pCfg);
|
|
124
|
+
const defaultProvider = pCfg.localModel?.provider ?? "ollama";
|
|
125
|
+
return {
|
|
126
|
+
provider: guardCfg?.provider ?? defaultProvider,
|
|
127
|
+
model: guardCfg?.modelName ?? pCfg.localModel?.model ?? "openbmb/minicpm4.1",
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
// S2
|
|
131
|
+
const s2Policy = pCfg.s2Policy ?? "proxy";
|
|
132
|
+
if (s2Policy === "local") {
|
|
133
|
+
const guardCfg = getGuardAgentConfig(pCfg);
|
|
134
|
+
const defaultProvider = pCfg.localModel?.provider ?? "ollama";
|
|
135
|
+
return {
|
|
136
|
+
provider: guardCfg?.provider ?? defaultProvider,
|
|
137
|
+
model: guardCfg?.modelName ?? pCfg.localModel?.model ?? "openbmb/minicpm4.1",
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
return { provider: "clawxrouter-privacy", model: "" };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Create a configurable router instance with the given ID.
|
|
145
|
+
* The router reads its options (keywords, patterns, prompt) from the
|
|
146
|
+
* plugin config at runtime so dashboard changes take effect immediately.
|
|
147
|
+
*/
|
|
148
|
+
export function createConfigurableRouter(id: string): ClawXrouterRouter {
|
|
149
|
+
return {
|
|
150
|
+
id,
|
|
151
|
+
async detect(
|
|
152
|
+
context: DetectionContext,
|
|
153
|
+
pluginConfig: Record<string, unknown>,
|
|
154
|
+
): Promise<RouterDecision> {
|
|
155
|
+
const opts = getOptions(id, pluginConfig);
|
|
156
|
+
const text = context.message ?? "";
|
|
157
|
+
const levels: SensitivityLevel[] = [];
|
|
158
|
+
const reasons: string[] = [];
|
|
159
|
+
|
|
160
|
+
// Keyword matching
|
|
161
|
+
if (opts.keywords && text) {
|
|
162
|
+
const kw = checkKeywords(text, opts.keywords);
|
|
163
|
+
if (kw.level !== "S1") {
|
|
164
|
+
levels.push(kw.level);
|
|
165
|
+
if (kw.reason) reasons.push(kw.reason);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Pattern matching
|
|
170
|
+
if (opts.patterns && text) {
|
|
171
|
+
const pat = checkPatterns(text, opts.patterns);
|
|
172
|
+
if (pat.level !== "S1") {
|
|
173
|
+
levels.push(pat.level);
|
|
174
|
+
if (pat.reason) reasons.push(pat.reason);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// LLM prompt classification (only if no keyword/pattern hit or for extra accuracy)
|
|
179
|
+
if (opts.prompt && text) {
|
|
180
|
+
const llm = await classifyWithPrompt(text, opts.prompt, pluginConfig);
|
|
181
|
+
if (llm && llm.level !== "S1") {
|
|
182
|
+
levels.push(llm.level);
|
|
183
|
+
if (llm.reason) reasons.push(llm.reason);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (levels.length === 0) {
|
|
188
|
+
return { level: "S1", action: "passthrough", reason: "No match" };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const finalLevel = maxLevel(...levels);
|
|
192
|
+
const action = (opts.action ?? "redirect") as RouterAction;
|
|
193
|
+
|
|
194
|
+
let target: { provider: string; model: string } | undefined;
|
|
195
|
+
if (finalLevel !== "S1" && action === "redirect") {
|
|
196
|
+
target = resolveTargetForLevel(finalLevel, pluginConfig);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return {
|
|
200
|
+
level: finalLevel,
|
|
201
|
+
action,
|
|
202
|
+
target,
|
|
203
|
+
reason: reasons.join("; "),
|
|
204
|
+
confidence: levels.some((l) => l !== "S1") ? 0.8 : 0.5,
|
|
205
|
+
};
|
|
206
|
+
},
|
|
207
|
+
};
|
|
208
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
DetectionContext,
|
|
3
|
+
ClawXrouterRouter,
|
|
4
|
+
PrivacyConfig,
|
|
5
|
+
RouterDecision,
|
|
6
|
+
SensitivityLevel,
|
|
7
|
+
} from "../types.js";
|
|
8
|
+
import { detectSensitivityLevel } from "../detector.js";
|
|
9
|
+
import { desensitizeWithLocalModel } from "../local-model.js";
|
|
10
|
+
import { getGuardAgentConfig } from "../guard-agent.js";
|
|
11
|
+
import { defaultPrivacyConfig } from "../config-schema.js";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Map a DetectionResult (from detector.ts) into a RouterDecision.
|
|
15
|
+
* This bridges the legacy detector API to the new router pipeline API.
|
|
16
|
+
*/
|
|
17
|
+
function detectionToDecision(
|
|
18
|
+
level: SensitivityLevel,
|
|
19
|
+
reason: string | undefined,
|
|
20
|
+
privacyConfig: PrivacyConfig,
|
|
21
|
+
): RouterDecision {
|
|
22
|
+
if (level === "S1") {
|
|
23
|
+
return { level: "S1", action: "passthrough", reason };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (level === "S3") {
|
|
27
|
+
const guardCfg = getGuardAgentConfig(privacyConfig);
|
|
28
|
+
const defaultProvider = privacyConfig.localModel?.provider ?? "ollama";
|
|
29
|
+
return {
|
|
30
|
+
level: "S3",
|
|
31
|
+
action: "redirect",
|
|
32
|
+
target: {
|
|
33
|
+
provider: guardCfg?.provider ?? defaultProvider,
|
|
34
|
+
model: guardCfg?.modelName ?? privacyConfig.localModel?.model ?? "openbmb/minicpm4.1",
|
|
35
|
+
},
|
|
36
|
+
reason,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// S2
|
|
41
|
+
const s2Policy = privacyConfig.s2Policy ?? "proxy";
|
|
42
|
+
if (s2Policy === "local") {
|
|
43
|
+
const guardCfg = getGuardAgentConfig(privacyConfig);
|
|
44
|
+
const defaultProvider = privacyConfig.localModel?.provider ?? "ollama";
|
|
45
|
+
return {
|
|
46
|
+
level: "S2",
|
|
47
|
+
action: "redirect",
|
|
48
|
+
target: {
|
|
49
|
+
provider: guardCfg?.provider ?? defaultProvider,
|
|
50
|
+
model: guardCfg?.modelName ?? privacyConfig.localModel?.model ?? "openbmb/minicpm4.1",
|
|
51
|
+
},
|
|
52
|
+
reason,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// s2Policy === "proxy"
|
|
57
|
+
return {
|
|
58
|
+
level: "S2",
|
|
59
|
+
action: "redirect",
|
|
60
|
+
target: { provider: "clawxrouter-privacy", model: "" },
|
|
61
|
+
reason,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function getPrivacyConfig(pluginConfig: Record<string, unknown>): PrivacyConfig {
|
|
66
|
+
const userConfig = (pluginConfig?.privacy as PrivacyConfig) ?? {};
|
|
67
|
+
return {
|
|
68
|
+
...defaultPrivacyConfig,
|
|
69
|
+
...userConfig,
|
|
70
|
+
checkpoints: { ...defaultPrivacyConfig.checkpoints, ...userConfig.checkpoints },
|
|
71
|
+
rules: {
|
|
72
|
+
keywords: { ...defaultPrivacyConfig.rules.keywords, ...userConfig.rules?.keywords },
|
|
73
|
+
patterns: { ...defaultPrivacyConfig.rules.patterns, ...userConfig.rules?.patterns },
|
|
74
|
+
tools: {
|
|
75
|
+
S2: { ...defaultPrivacyConfig.rules.tools.S2, ...userConfig.rules?.tools?.S2 },
|
|
76
|
+
S3: { ...defaultPrivacyConfig.rules.tools.S3, ...userConfig.rules?.tools?.S3 },
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
localModel: { ...defaultPrivacyConfig.localModel, ...userConfig.localModel },
|
|
80
|
+
guardAgent: { ...defaultPrivacyConfig.guardAgent, ...userConfig.guardAgent },
|
|
81
|
+
session: { ...defaultPrivacyConfig.session, ...userConfig.session },
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export const privacyRouter: ClawXrouterRouter = {
|
|
86
|
+
id: "privacy",
|
|
87
|
+
|
|
88
|
+
async detect(
|
|
89
|
+
context: DetectionContext,
|
|
90
|
+
pluginConfig: Record<string, unknown>,
|
|
91
|
+
): Promise<RouterDecision> {
|
|
92
|
+
const privacyConfig = getPrivacyConfig(pluginConfig);
|
|
93
|
+
|
|
94
|
+
if (privacyConfig.enabled === false && !context.dryRun) {
|
|
95
|
+
return { level: "S1", action: "passthrough", reason: "Privacy detection disabled" };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const result = await detectSensitivityLevel(context, pluginConfig, privacyConfig);
|
|
99
|
+
|
|
100
|
+
return detectionToDecision(result.level, result.reason, privacyConfig);
|
|
101
|
+
},
|
|
102
|
+
};
|