@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,101 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
|
2
|
+
import { resolve, dirname } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
6
|
+
const __dirname = dirname(__filename);
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Resolve the prompts/ directory.
|
|
10
|
+
* Works whether running from source (src/) or compiled output (dist/src/).
|
|
11
|
+
*/
|
|
12
|
+
function resolvePromptsDir(): string {
|
|
13
|
+
const candidates = [
|
|
14
|
+
resolve(__dirname, "../prompts"), // from src/ → prompts/
|
|
15
|
+
resolve(__dirname, "../../prompts"), // from dist/src/ → prompts/
|
|
16
|
+
];
|
|
17
|
+
for (const dir of candidates) {
|
|
18
|
+
if (existsSync(dir)) return dir;
|
|
19
|
+
}
|
|
20
|
+
return candidates[0]; // fallback, will trigger per-file fallback
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const PROMPTS_DIR = resolvePromptsDir();
|
|
24
|
+
|
|
25
|
+
/** Cache loaded prompts in memory — invalidated on dashboard save */
|
|
26
|
+
const cache = new Map<string, string>();
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Load a prompt template from `prompts/{name}.md`.
|
|
30
|
+
* Returns the file content if found, otherwise returns the fallback string.
|
|
31
|
+
*
|
|
32
|
+
* Results are cached — the file is read only once per process lifetime.
|
|
33
|
+
*/
|
|
34
|
+
export function loadPrompt(name: string, fallback: string): string {
|
|
35
|
+
const cached = cache.get(name);
|
|
36
|
+
if (cached !== undefined) return cached;
|
|
37
|
+
|
|
38
|
+
const filePath = resolve(PROMPTS_DIR, `${name}.md`);
|
|
39
|
+
let content: string;
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
if (existsSync(filePath)) {
|
|
43
|
+
content = readFileSync(filePath, "utf-8").trim();
|
|
44
|
+
console.log(`[ClawXrouter] Loaded custom prompt: prompts/${name}.md`);
|
|
45
|
+
} else {
|
|
46
|
+
content = fallback;
|
|
47
|
+
}
|
|
48
|
+
} catch {
|
|
49
|
+
console.warn(`[ClawXrouter] Failed to read prompts/${name}.md, using default`);
|
|
50
|
+
content = fallback;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
cache.set(name, content);
|
|
54
|
+
return content;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Load a prompt and replace `{{PLACEHOLDER}}` tokens with provided values.
|
|
59
|
+
*/
|
|
60
|
+
export function loadPromptWithVars(
|
|
61
|
+
name: string,
|
|
62
|
+
fallback: string,
|
|
63
|
+
vars: Record<string, string>,
|
|
64
|
+
): string {
|
|
65
|
+
let prompt = loadPrompt(name, fallback);
|
|
66
|
+
for (const [key, value] of Object.entries(vars)) {
|
|
67
|
+
prompt = prompt.replaceAll(`{{${key}}}`, value);
|
|
68
|
+
}
|
|
69
|
+
return prompt;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Invalidate a cached prompt so the next loadPrompt() re-reads from disk. */
|
|
73
|
+
export function invalidatePrompt(name: string): void {
|
|
74
|
+
cache.delete(name);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Write a prompt to `prompts/{name}.md` and invalidate its cache.
|
|
79
|
+
* Creates the prompts directory if it doesn't exist.
|
|
80
|
+
*/
|
|
81
|
+
export function writePrompt(name: string, content: string): void {
|
|
82
|
+
mkdirSync(PROMPTS_DIR, { recursive: true });
|
|
83
|
+
const filePath = resolve(PROMPTS_DIR, `${name}.md`);
|
|
84
|
+
writeFileSync(filePath, content, "utf-8");
|
|
85
|
+
invalidatePrompt(name);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Read a prompt file directly from disk (bypasses cache).
|
|
90
|
+
* Returns null if the file doesn't exist.
|
|
91
|
+
*/
|
|
92
|
+
export function readPromptFromDisk(name: string): string | null {
|
|
93
|
+
const filePath = resolve(PROMPTS_DIR, `${name}.md`);
|
|
94
|
+
try {
|
|
95
|
+
if (existsSync(filePath)) {
|
|
96
|
+
return readFileSync(filePath, "utf-8").trim();
|
|
97
|
+
}
|
|
98
|
+
} catch { /* file unreadable */ }
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
|
package/src/provider.ts
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import type { ProxyHandle } from "./privacy-proxy.js";
|
|
2
|
+
import { registerModelTarget, type OriginalProviderTarget } from "./privacy-proxy.js";
|
|
3
|
+
import { resolveDefaultBaseUrl } from "./utils.js";
|
|
4
|
+
|
|
5
|
+
let activeProxy: ProxyHandle | null = null;
|
|
6
|
+
|
|
7
|
+
export function setActiveProxy(proxy: ProxyHandle): void {
|
|
8
|
+
activeProxy = proxy;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const clawXrouterPrivacyProvider = {
|
|
12
|
+
id: "clawxrouter-privacy",
|
|
13
|
+
label: "ClawXrouter Privacy Proxy",
|
|
14
|
+
aliases: [] as string[],
|
|
15
|
+
envVars: [] as string[],
|
|
16
|
+
auth: [] as never[],
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// Phase 1: Init-time model collection
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Mirror all model definitions from every configured provider and
|
|
25
|
+
* build the model→target map for proxy routing.
|
|
26
|
+
*/
|
|
27
|
+
export function mirrorAllProviderModels(
|
|
28
|
+
config: { models?: { providers?: Record<string, { models?: unknown; baseUrl?: string; apiKey?: string; api?: string }> } },
|
|
29
|
+
): unknown[] {
|
|
30
|
+
const seen = new Set<string>();
|
|
31
|
+
const mirrored: unknown[] = [];
|
|
32
|
+
const providers = config.models?.providers ?? {};
|
|
33
|
+
|
|
34
|
+
for (const [provName, provCfg] of Object.entries(providers)) {
|
|
35
|
+
if (provName === "clawxrouter-privacy") continue;
|
|
36
|
+
if (!provCfg.models) continue;
|
|
37
|
+
|
|
38
|
+
const target: OriginalProviderTarget = {
|
|
39
|
+
baseUrl: provCfg.baseUrl ?? resolveDefaultBaseUrl(provName, provCfg.api),
|
|
40
|
+
apiKey: provCfg.apiKey ?? "",
|
|
41
|
+
provider: provName,
|
|
42
|
+
api: provCfg.api,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const models = provCfg.models;
|
|
46
|
+
if (Array.isArray(models)) {
|
|
47
|
+
for (const m of models) {
|
|
48
|
+
const id = (m as Record<string, unknown>)?.id as string | undefined;
|
|
49
|
+
if (id && !seen.has(id)) {
|
|
50
|
+
seen.add(id);
|
|
51
|
+
mirrored.push(m);
|
|
52
|
+
registerModelTarget(id, target);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
} else if (typeof models === "object" && models !== null) {
|
|
56
|
+
for (const [modelId, modelDef] of Object.entries(models as Record<string, unknown>)) {
|
|
57
|
+
if (!seen.has(modelId)) {
|
|
58
|
+
seen.add(modelId);
|
|
59
|
+
mirrored.push({ id: modelId, ...(typeof modelDef === "object" && modelDef !== null ? modelDef as Record<string, unknown> : {}) });
|
|
60
|
+
registerModelTarget(modelId, target);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return mirrored;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Scan router tier configs for model IDs that may not be in any provider's
|
|
71
|
+
* explicit model list. Returns { provider, modelId } pairs.
|
|
72
|
+
*/
|
|
73
|
+
export function collectTierModelIds(
|
|
74
|
+
pluginConfig: Record<string, unknown>,
|
|
75
|
+
): Array<{ provider: string; modelId: string }> {
|
|
76
|
+
const privacy = (pluginConfig?.privacy ?? {}) as Record<string, unknown>;
|
|
77
|
+
const routers = (privacy.routers ?? {}) as Record<
|
|
78
|
+
string,
|
|
79
|
+
{ options?: { tiers?: Record<string, { provider?: string; model?: string }> } }
|
|
80
|
+
>;
|
|
81
|
+
const result: Array<{ provider: string; modelId: string }> = [];
|
|
82
|
+
|
|
83
|
+
for (const reg of Object.values(routers)) {
|
|
84
|
+
const tiers = reg.options?.tiers;
|
|
85
|
+
if (!tiers) continue;
|
|
86
|
+
for (const tier of Object.values(tiers)) {
|
|
87
|
+
if (tier.provider && tier.model) {
|
|
88
|
+
result.push({ provider: tier.provider, modelId: tier.model });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return result;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
// Phase 2: JIT (runtime) model registration
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
type ProviderCfg = Record<string, unknown> & {
|
|
100
|
+
models?: Array<Record<string, unknown>>;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Look up a model definition across all configured providers (excluding
|
|
105
|
+
* clawxrouter-privacy). Returns the full model object if found, null otherwise.
|
|
106
|
+
*/
|
|
107
|
+
function findModelInProviders(
|
|
108
|
+
providers: Record<string, ProviderCfg>,
|
|
109
|
+
modelId: string,
|
|
110
|
+
preferProvider?: string,
|
|
111
|
+
): Record<string, unknown> | null {
|
|
112
|
+
const order = preferProvider
|
|
113
|
+
? [preferProvider, ...Object.keys(providers).filter((p) => p !== preferProvider && p !== "clawxrouter-privacy")]
|
|
114
|
+
: Object.keys(providers).filter((p) => p !== "clawxrouter-privacy");
|
|
115
|
+
|
|
116
|
+
for (const provName of order) {
|
|
117
|
+
const provModels = providers[provName]?.models;
|
|
118
|
+
if (!Array.isArray(provModels)) continue;
|
|
119
|
+
const found = provModels.find((m) => (m as Record<string, unknown>).id === modelId);
|
|
120
|
+
if (found) return { ...(found as Record<string, unknown>) };
|
|
121
|
+
}
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Build a minimal model entry that matches OpenClaw's resolveModelWithRegistry
|
|
127
|
+
* fallback for the given provider. This ensures contextWindow / maxTokens /
|
|
128
|
+
* reasoning align with what the model would get if routed directly.
|
|
129
|
+
*/
|
|
130
|
+
function buildFallbackModelEntry(
|
|
131
|
+
providers: Record<string, ProviderCfg>,
|
|
132
|
+
modelId: string,
|
|
133
|
+
originalProvider: string,
|
|
134
|
+
): Record<string, unknown> {
|
|
135
|
+
const origModels = providers[originalProvider]?.models;
|
|
136
|
+
const firstModel =
|
|
137
|
+
Array.isArray(origModels) && origModels.length > 0
|
|
138
|
+
? (origModels[0] as Record<string, unknown>)
|
|
139
|
+
: null;
|
|
140
|
+
return {
|
|
141
|
+
id: modelId,
|
|
142
|
+
name: modelId,
|
|
143
|
+
...(firstModel?.contextWindow != null ? { contextWindow: firstModel.contextWindow } : {}),
|
|
144
|
+
...(firstModel?.maxTokens != null ? { maxTokens: firstModel.maxTokens } : {}),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Ensure `modelId` is registered under the clawxrouter-privacy provider.
|
|
150
|
+
*
|
|
151
|
+
* Called at decision time (JIT) so that models selected by token-saver or
|
|
152
|
+
* other routers — which may not have been in the init snapshot — are available
|
|
153
|
+
* with correct properties before OpenClaw's resolveModel runs.
|
|
154
|
+
*
|
|
155
|
+
* Also propagates reasoning/thinking defaults into agents.defaults.models so
|
|
156
|
+
* thinking-model output isn't stripped.
|
|
157
|
+
*/
|
|
158
|
+
export function ensureModelMirrored(
|
|
159
|
+
config: Record<string, unknown>,
|
|
160
|
+
modelId: string,
|
|
161
|
+
originalProvider: string,
|
|
162
|
+
runtimeLoadConfig?: () => Record<string, unknown> | undefined,
|
|
163
|
+
): void {
|
|
164
|
+
const providers = (config as Record<string, unknown> & { models?: { providers?: Record<string, ProviderCfg> } })
|
|
165
|
+
.models?.providers;
|
|
166
|
+
if (!providers?.["clawxrouter-privacy"]) return;
|
|
167
|
+
|
|
168
|
+
const privacyModels = providers["clawxrouter-privacy"].models;
|
|
169
|
+
if (!Array.isArray(privacyModels)) return;
|
|
170
|
+
|
|
171
|
+
const alreadyMirrored = privacyModels.some((m) => (m as Record<string, unknown>).id === modelId);
|
|
172
|
+
|
|
173
|
+
let source: Record<string, unknown>;
|
|
174
|
+
if (alreadyMirrored) {
|
|
175
|
+
source = privacyModels.find((m) => (m as Record<string, unknown>).id === modelId) as Record<string, unknown>;
|
|
176
|
+
} else {
|
|
177
|
+
source =
|
|
178
|
+
findModelInProviders(providers, modelId, originalProvider) ??
|
|
179
|
+
buildFallbackModelEntry(providers, modelId, originalProvider);
|
|
180
|
+
privacyModels.push(source);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Register model→provider target for proxy routing (idempotent)
|
|
184
|
+
const provCfg = providers[originalProvider];
|
|
185
|
+
if (provCfg) {
|
|
186
|
+
registerModelTarget(modelId, {
|
|
187
|
+
baseUrl: (provCfg as Record<string, unknown>).baseUrl as string ?? resolveDefaultBaseUrl(originalProvider, (provCfg as Record<string, unknown>).api as string | undefined),
|
|
188
|
+
apiKey: (provCfg as Record<string, unknown>).apiKey as string ?? "",
|
|
189
|
+
provider: originalProvider,
|
|
190
|
+
api: (provCfg as Record<string, unknown>).api as string | undefined,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (source.reasoning === true) {
|
|
195
|
+
propagateThinkingForModel(config, modelId);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Patch the runtime config snapshot (structuredClone of api.config)
|
|
199
|
+
if (runtimeLoadConfig) {
|
|
200
|
+
try {
|
|
201
|
+
const rtCfg = runtimeLoadConfig();
|
|
202
|
+
if (rtCfg) {
|
|
203
|
+
if (!alreadyMirrored) {
|
|
204
|
+
const rtProviders = (rtCfg as Record<string, unknown> & { models?: { providers?: Record<string, ProviderCfg> } })
|
|
205
|
+
.models?.providers;
|
|
206
|
+
const rtModels = rtProviders?.["clawxrouter-privacy"]?.models;
|
|
207
|
+
if (Array.isArray(rtModels) && !rtModels.some((m) => (m as Record<string, unknown>).id === modelId)) {
|
|
208
|
+
rtModels.push(source);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
if (source.reasoning === true) {
|
|
212
|
+
propagateThinkingForModel(rtCfg, modelId);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
} catch { /* best-effort */ }
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Set `params.thinking: "low"` for a clawxrouter-privacy model so the agent SDK
|
|
221
|
+
* enables thinking mode for reasoning models routed through the proxy.
|
|
222
|
+
*/
|
|
223
|
+
function propagateThinkingForModel(
|
|
224
|
+
config: Record<string, unknown>,
|
|
225
|
+
modelId: string,
|
|
226
|
+
): void {
|
|
227
|
+
const agents = (config as Record<string, unknown> & { agents?: Record<string, unknown> }).agents;
|
|
228
|
+
const defaults = agents?.defaults as Record<string, unknown> | undefined;
|
|
229
|
+
if (!defaults) return;
|
|
230
|
+
if (!defaults.models) defaults.models = {};
|
|
231
|
+
const modelsOverrides = defaults.models as Record<string, Record<string, unknown>>;
|
|
232
|
+
const proxyKey = `clawxrouter-privacy/${modelId}`;
|
|
233
|
+
const existing = modelsOverrides[proxyKey] ?? {};
|
|
234
|
+
if (!existing.params || !(existing.params as Record<string, unknown>).thinking) {
|
|
235
|
+
modelsOverrides[proxyKey] = {
|
|
236
|
+
...existing,
|
|
237
|
+
params: { ...(existing.params as Record<string, unknown> ?? {}), thinking: "low" },
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ---------------------------------------------------------------------------
|
|
243
|
+
// Helpers for hooks.ts: resolve original provider for a model
|
|
244
|
+
// ---------------------------------------------------------------------------
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Given a model ID selected by the pipeline (e.g. from token-saver), find
|
|
248
|
+
* which real provider owns it. Returns the provider name, or the supplied
|
|
249
|
+
* fallback if no explicit match is found.
|
|
250
|
+
*/
|
|
251
|
+
export function resolveOriginalProvider(
|
|
252
|
+
config: Record<string, unknown>,
|
|
253
|
+
modelId: string,
|
|
254
|
+
fallbackProvider: string,
|
|
255
|
+
): string {
|
|
256
|
+
const providers = (config as Record<string, unknown> & { models?: { providers?: Record<string, ProviderCfg> } })
|
|
257
|
+
.models?.providers ?? {};
|
|
258
|
+
|
|
259
|
+
for (const [provName, provCfg] of Object.entries(providers)) {
|
|
260
|
+
if (provName === "clawxrouter-privacy") continue;
|
|
261
|
+
const provModels = provCfg.models;
|
|
262
|
+
if (!Array.isArray(provModels)) continue;
|
|
263
|
+
if (provModels.some((m) => (m as Record<string, unknown>).id === modelId)) {
|
|
264
|
+
return provName;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return fallbackProvider;
|
|
268
|
+
}
|