@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,204 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://docs.openclaw.ai/schema.json",
|
|
3
|
+
"plugins": {
|
|
4
|
+
"enabled": ["clawxrouter"]
|
|
5
|
+
},
|
|
6
|
+
"privacy": {
|
|
7
|
+
"enabled": true,
|
|
8
|
+
"s2Policy": "proxy",
|
|
9
|
+
"proxyPort": 8403,
|
|
10
|
+
"checkpoints": {
|
|
11
|
+
"onUserMessage": ["ruleDetector", "localModelDetector"],
|
|
12
|
+
"onToolCallProposed": ["ruleDetector"],
|
|
13
|
+
"onToolCallExecuted": ["ruleDetector", "localModelDetector"]
|
|
14
|
+
},
|
|
15
|
+
"rules": {
|
|
16
|
+
"keywords": {
|
|
17
|
+
"S2": [
|
|
18
|
+
"password", "api_key", "secret", "token", "credential", "auth_token",
|
|
19
|
+
"salary", "地址", "电话", "手机号", "合同", "客户", "甲方", "乙方",
|
|
20
|
+
"交易", "金额", "intranet", "域控"
|
|
21
|
+
],
|
|
22
|
+
"S3": [
|
|
23
|
+
"ssh", "id_rsa", "private_key", ".pem", ".key", ".env", "master_password",
|
|
24
|
+
"身份证", "银行卡", "社保", "病历", "诊断", "处方", "密码", "密钥",
|
|
25
|
+
"简历", "resume"
|
|
26
|
+
]
|
|
27
|
+
},
|
|
28
|
+
"patterns": {
|
|
29
|
+
"S2": [
|
|
30
|
+
"\\b(?:10|172\\.(?:1[6-9]|2\\d|3[01])|192\\.168)\\.\\d{1,3}\\.\\d{1,3}\\b",
|
|
31
|
+
"(?:mysql|postgres|mongodb|redis)://[^\\s]+",
|
|
32
|
+
"\\b(?:sk|key|token)-[A-Za-z0-9]{16,}\\b",
|
|
33
|
+
"1[3-9]\\d{9}",
|
|
34
|
+
"(?i)ghp_[a-zA-Z0-9]{36}",
|
|
35
|
+
"(?i)xox[bsrap]-[a-zA-Z0-9-]+",
|
|
36
|
+
"(?i)(?:contract|agreement)[-_]?\\w{6,}",
|
|
37
|
+
"(?i)¥[\\d,]+\\.?\\d*|\\$[\\d,]+\\.?\\d*",
|
|
38
|
+
"(?i)[a-z]+-(?:srv|dc|db|web|app)-\\d+",
|
|
39
|
+
"(?i)[a-z]+\\\\[a-z0-9._-]+"
|
|
40
|
+
],
|
|
41
|
+
"S3": [
|
|
42
|
+
"-----BEGIN (?:RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----",
|
|
43
|
+
"AKIA[0-9A-Z]{16}",
|
|
44
|
+
"\\d{17}[0-9Xx]",
|
|
45
|
+
"\\d{4}[\\s-]?\\d{4}[\\s-]?\\d{4}[\\s-]?\\d{4}",
|
|
46
|
+
"(?i)(password|passwd|pwd)\\s*[=:]\\s*['\"][^'\"]{8,}"
|
|
47
|
+
]
|
|
48
|
+
},
|
|
49
|
+
"tools": {
|
|
50
|
+
"S2": {
|
|
51
|
+
"tools": ["execute_sql"],
|
|
52
|
+
"paths": ["~/secrets", "~/private"]
|
|
53
|
+
},
|
|
54
|
+
"S3": {
|
|
55
|
+
"tools": ["sudo"],
|
|
56
|
+
"paths": ["~/.ssh", "~/.aws", "~/.config/credentials", "/root", "/credentials/"]
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
|
|
61
|
+
"_comment_localModel": "Edge provider examples — pick ONE of the configurations below",
|
|
62
|
+
|
|
63
|
+
"_example_ollama": {
|
|
64
|
+
"localModel": {
|
|
65
|
+
"enabled": true,
|
|
66
|
+
"type": "openai-compatible",
|
|
67
|
+
"provider": "ollama",
|
|
68
|
+
"model": "minicpm4.1",
|
|
69
|
+
"endpoint": "http://localhost:11434"
|
|
70
|
+
},
|
|
71
|
+
"guardAgent": { "model": "ollama/minicpm4.1" }
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
"_example_ollama_native": {
|
|
75
|
+
"localModel": {
|
|
76
|
+
"enabled": true,
|
|
77
|
+
"type": "ollama-native",
|
|
78
|
+
"provider": "ollama",
|
|
79
|
+
"model": "minicpm4.1",
|
|
80
|
+
"endpoint": "http://localhost:11434"
|
|
81
|
+
},
|
|
82
|
+
"guardAgent": { "model": "ollama/minicpm4.1" }
|
|
83
|
+
},
|
|
84
|
+
|
|
85
|
+
"_example_vllm": {
|
|
86
|
+
"localModel": {
|
|
87
|
+
"enabled": true,
|
|
88
|
+
"type": "openai-compatible",
|
|
89
|
+
"provider": "vllm",
|
|
90
|
+
"model": "openbmb/MiniCPM4.1",
|
|
91
|
+
"endpoint": "http://localhost:8000"
|
|
92
|
+
},
|
|
93
|
+
"guardAgent": { "model": "vllm/openbmb/MiniCPM4.1" }
|
|
94
|
+
},
|
|
95
|
+
|
|
96
|
+
"_example_lmstudio": {
|
|
97
|
+
"localModel": {
|
|
98
|
+
"enabled": true,
|
|
99
|
+
"type": "openai-compatible",
|
|
100
|
+
"provider": "lmstudio",
|
|
101
|
+
"model": "openbmb/MiniCPM4.1-GGUF",
|
|
102
|
+
"endpoint": "http://localhost:1234"
|
|
103
|
+
},
|
|
104
|
+
"guardAgent": { "model": "lmstudio/openbmb/MiniCPM4.1-GGUF" }
|
|
105
|
+
},
|
|
106
|
+
|
|
107
|
+
"_example_sglang": {
|
|
108
|
+
"localModel": {
|
|
109
|
+
"enabled": true,
|
|
110
|
+
"type": "openai-compatible",
|
|
111
|
+
"provider": "sglang",
|
|
112
|
+
"model": "openbmb/MiniCPM4.1",
|
|
113
|
+
"endpoint": "http://localhost:30000"
|
|
114
|
+
},
|
|
115
|
+
"guardAgent": { "model": "sglang/openbmb/MiniCPM4.1" }
|
|
116
|
+
},
|
|
117
|
+
|
|
118
|
+
"_example_custom": {
|
|
119
|
+
"localModel": {
|
|
120
|
+
"enabled": true,
|
|
121
|
+
"type": "custom",
|
|
122
|
+
"provider": "my-inference",
|
|
123
|
+
"model": "my-model",
|
|
124
|
+
"endpoint": "http://localhost:9999",
|
|
125
|
+
"module": "./my-edge-provider.js"
|
|
126
|
+
},
|
|
127
|
+
"localProviders": ["my-inference"],
|
|
128
|
+
"guardAgent": { "model": "my-inference/my-model" }
|
|
129
|
+
},
|
|
130
|
+
|
|
131
|
+
"localModel": {
|
|
132
|
+
"enabled": true,
|
|
133
|
+
"type": "openai-compatible",
|
|
134
|
+
"provider": "ollama",
|
|
135
|
+
"model": "minicpm4.1",
|
|
136
|
+
"endpoint": "http://localhost:11434"
|
|
137
|
+
},
|
|
138
|
+
"guardAgent": {
|
|
139
|
+
"id": "guard",
|
|
140
|
+
"workspace": "~/.openclaw/workspace-guard",
|
|
141
|
+
"model": "ollama/minicpm4.1"
|
|
142
|
+
},
|
|
143
|
+
"routers": {
|
|
144
|
+
"privacy": { "enabled": true, "type": "builtin", "weight": 90 },
|
|
145
|
+
"token-saver": {
|
|
146
|
+
"enabled": true,
|
|
147
|
+
"type": "builtin",
|
|
148
|
+
"options": {
|
|
149
|
+
"judgeEndpoint": "https://openrouter.ai/api/v1",
|
|
150
|
+
"judgeModel": "gemini-2.5-flash",
|
|
151
|
+
"judgeProviderType": "openai-compatible",
|
|
152
|
+
"defaultTier": "MEDIUM",
|
|
153
|
+
"tiers": {
|
|
154
|
+
"SIMPLE": {
|
|
155
|
+
"provider": "openrouter",
|
|
156
|
+
"model": "glm-4.5-air",
|
|
157
|
+
"description": "lookup, greeting, yes/no, factual questions with short answers, confirming readiness, reading a short file to answer ONE factual question"
|
|
158
|
+
},
|
|
159
|
+
"MEDIUM": {
|
|
160
|
+
"provider": "openrouter",
|
|
161
|
+
"model": "minimax-m2.5",
|
|
162
|
+
"description": "moderate writing (email, blog, letter), text rewriting or humanizing, using a skill on text, CSV/spreadsheet/Excel data analysis, search-and-replace across config files, summarizing a plain-text file"
|
|
163
|
+
},
|
|
164
|
+
"COMPLEX": {
|
|
165
|
+
"provider": "openrouter",
|
|
166
|
+
"model": "deepseek-v3.2",
|
|
167
|
+
"description": "code generation, file and project structure creation, multi-step workflows, email triage or classification across multiple messages, email search and summarization, competitive/market research, multi-file refactoring, creating calendar events or ICS files"
|
|
168
|
+
},
|
|
169
|
+
"RESEARCH": {
|
|
170
|
+
"provider": "openrouter",
|
|
171
|
+
"model": "glm-5",
|
|
172
|
+
"description": "tasks requiring web search or finding real-time information: stock prices, upcoming events or conferences, live market data, current news"
|
|
173
|
+
},
|
|
174
|
+
"REASONING": {
|
|
175
|
+
"provider": "openrouter",
|
|
176
|
+
"model": "kimi-k2.5",
|
|
177
|
+
"description": "reading a PDF or long document then summarizing or explaining it, answering questions about a PDF or report, math proof, formal logic, structured information extraction from lengthy documents"
|
|
178
|
+
}
|
|
179
|
+
},
|
|
180
|
+
"rules": [
|
|
181
|
+
"Writing a blog post, email, or letter from scratch → MEDIUM.",
|
|
182
|
+
"CSV/Excel data processing and summarization → MEDIUM.",
|
|
183
|
+
"Creating Python scripts, project structures, or ICS files → COMPLEX.",
|
|
184
|
+
"Reading multiple emails/files and classifying them → COMPLEX.",
|
|
185
|
+
"Searching the web for real-world data (stock, events) → RESEARCH.",
|
|
186
|
+
"Reading a PDF then summarizing or answering questions → REASONING."
|
|
187
|
+
]
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
},
|
|
191
|
+
"localProviders": [],
|
|
192
|
+
"session": {
|
|
193
|
+
"isolateGuardHistory": true,
|
|
194
|
+
"baseDir": "~/.openclaw",
|
|
195
|
+
"injectDualHistory": true,
|
|
196
|
+
"historyLimit": 20
|
|
197
|
+
},
|
|
198
|
+
"pipeline": {
|
|
199
|
+
"onUserMessage": ["token-saver"],
|
|
200
|
+
"onToolCallProposed": [],
|
|
201
|
+
"onToolCallExecuted": ["privacy"]
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
package/index.ts
ADDED
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
|
2
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
5
|
+
import { clawXrouterConfigSchema, defaultPrivacyConfig } from "./src/config-schema.js";
|
|
6
|
+
import { registerHooks } from "./src/hooks.js";
|
|
7
|
+
import { clawXrouterPrivacyProvider, setActiveProxy, mirrorAllProviderModels, collectTierModelIds, ensureModelMirrored } from "./src/provider.js";
|
|
8
|
+
import { startPrivacyProxy, setDefaultProviderTarget, registerModelTarget } from "./src/privacy-proxy.js";
|
|
9
|
+
import { RouterPipeline, setGlobalPipeline } from "./src/router-pipeline.js";
|
|
10
|
+
import { privacyRouter } from "./src/routers/privacy.js";
|
|
11
|
+
import { tokenSaverRouter } from "./src/routers/token-saver.js";
|
|
12
|
+
import { TokenStatsCollector, setGlobalCollector } from "./src/token-stats.js";
|
|
13
|
+
import { initLiveConfig, watchConfigFile } from "./src/live-config.js";
|
|
14
|
+
import { initDashboard, statsHttpHandler } from "./src/stats-dashboard.js";
|
|
15
|
+
import type { PrivacyConfig, PipelineConfig, RouterRegistration } from "./src/types.js";
|
|
16
|
+
import type { ProxyHandle } from "./src/privacy-proxy.js";
|
|
17
|
+
import { resolveDefaultBaseUrl } from "./src/utils.js";
|
|
18
|
+
|
|
19
|
+
const OPENCLAW_DIR = join(process.env.HOME ?? "/tmp", ".openclaw");
|
|
20
|
+
const CLAWXROUTER_CONFIG_PATH = join(OPENCLAW_DIR, "clawxrouter.json");
|
|
21
|
+
const LEGACY_DASHBOARD_PATH = join(OPENCLAW_DIR, "clawxrouter-dashboard.json");
|
|
22
|
+
|
|
23
|
+
function loadClawXrouterConfigFile(): Record<string, unknown> | null {
|
|
24
|
+
try {
|
|
25
|
+
return JSON.parse(readFileSync(CLAWXROUTER_CONFIG_PATH, "utf-8")) as Record<string, unknown>;
|
|
26
|
+
} catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function loadLegacyDashboardOverrides(): Record<string, unknown> | null {
|
|
32
|
+
try {
|
|
33
|
+
return JSON.parse(readFileSync(LEGACY_DASHBOARD_PATH, "utf-8")) as Record<string, unknown>;
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function writeClawXrouterConfigFile(config: Record<string, unknown>): void {
|
|
40
|
+
try {
|
|
41
|
+
mkdirSync(OPENCLAW_DIR, { recursive: true });
|
|
42
|
+
writeFileSync(CLAWXROUTER_CONFIG_PATH, JSON.stringify(config, null, 2), "utf-8");
|
|
43
|
+
} catch { /* best-effort */ }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function getPrivacyConfig(pluginConfig: Record<string, unknown> | undefined): PrivacyConfig {
|
|
47
|
+
const userConfig = (pluginConfig?.privacy ?? {}) as PrivacyConfig;
|
|
48
|
+
return { ...defaultPrivacyConfig, ...userConfig } as PrivacyConfig;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Determine the API type to register for the clawxrouter-privacy provider.
|
|
53
|
+
*
|
|
54
|
+
* The proxy is a transparent HTTP relay, so we need the SDK to send requests
|
|
55
|
+
* in a format that both the proxy can parse and the downstream provider accepts.
|
|
56
|
+
*
|
|
57
|
+
* - For Google-native APIs: use "openai-completions" since most Google gateways
|
|
58
|
+
* accept OpenAI format, and Google's native SDK may bypass the HTTP proxy.
|
|
59
|
+
* - For Anthropic: use "anthropic-messages" so the SDK sends the right format
|
|
60
|
+
* and auth scheme. The proxy handles forwarding transparently.
|
|
61
|
+
* - For everything else: use the original API type (usually "openai-completions").
|
|
62
|
+
*/
|
|
63
|
+
function resolveProxyApi(originalApi: string): string {
|
|
64
|
+
const api = originalApi.toLowerCase();
|
|
65
|
+
// Google native SDKs construct their own URLs and may bypass the HTTP proxy;
|
|
66
|
+
// fall back to openai-completions which Google gateways typically accept.
|
|
67
|
+
if (api.includes("google") || api.includes("gemini")) {
|
|
68
|
+
return "openai-completions";
|
|
69
|
+
}
|
|
70
|
+
// Anthropic's native API is proxy-friendly (standard HTTP POST to /v1/messages)
|
|
71
|
+
if (api === "anthropic-messages") {
|
|
72
|
+
return "anthropic-messages";
|
|
73
|
+
}
|
|
74
|
+
return originalApi;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export default definePluginEntry({
|
|
78
|
+
id: "ClawXRouter",
|
|
79
|
+
name: "ClawXRouter",
|
|
80
|
+
description: "Privacy-aware plugin with extensible router pipeline, guard agent, and built-in privacy proxy",
|
|
81
|
+
configSchema: clawXrouterConfigSchema,
|
|
82
|
+
|
|
83
|
+
register(api: OpenClawPluginApi) {
|
|
84
|
+
// ── Resolve config: clawxrouter.json > (openclaw.json + legacy overrides) ──
|
|
85
|
+
let resolvedPluginConfig: Record<string, unknown>;
|
|
86
|
+
const fileConfig = loadClawXrouterConfigFile();
|
|
87
|
+
if (fileConfig) {
|
|
88
|
+
resolvedPluginConfig = fileConfig;
|
|
89
|
+
api.logger.info("[ClawXrouter] Config loaded from clawxrouter.json");
|
|
90
|
+
} else {
|
|
91
|
+
// First run: generate clawxrouter.json from openclaw.json plugin config + defaults
|
|
92
|
+
const userPrivacy = ((api.pluginConfig ?? {}) as Record<string, unknown>).privacy as Record<string, unknown> | undefined;
|
|
93
|
+
const legacyOverrides = loadLegacyDashboardOverrides();
|
|
94
|
+
const mergedPrivacy = {
|
|
95
|
+
...defaultPrivacyConfig,
|
|
96
|
+
...(userPrivacy ?? {}),
|
|
97
|
+
...(legacyOverrides ?? {}),
|
|
98
|
+
};
|
|
99
|
+
if (legacyOverrides) {
|
|
100
|
+
api.logger.info("[ClawXrouter] Migrated legacy clawxrouter-dashboard.json overrides");
|
|
101
|
+
}
|
|
102
|
+
resolvedPluginConfig = { privacy: mergedPrivacy };
|
|
103
|
+
writeClawXrouterConfigFile(resolvedPluginConfig);
|
|
104
|
+
api.logger.info("[ClawXrouter] Generated clawxrouter.json with full defaults");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const privacyConfig = getPrivacyConfig(resolvedPluginConfig);
|
|
108
|
+
|
|
109
|
+
if (privacyConfig.enabled === false) {
|
|
110
|
+
api.logger.info("[ClawXrouter] Plugin disabled via config");
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
api.registerProvider(clawXrouterPrivacyProvider as Parameters<typeof api.registerProvider>[0]);
|
|
115
|
+
|
|
116
|
+
const proxyPort = privacyConfig.proxyPort ?? 8403;
|
|
117
|
+
if (!api.config.models) {
|
|
118
|
+
(api.config as Record<string, unknown>).models = { providers: {} };
|
|
119
|
+
}
|
|
120
|
+
const models = api.config.models as { providers?: Record<string, unknown> };
|
|
121
|
+
if (!models.providers) models.providers = {};
|
|
122
|
+
|
|
123
|
+
// Detect the default provider's API type so the proxy can adapt
|
|
124
|
+
const agentDefaults = (api.config.agents as Record<string, unknown> | undefined)?.defaults as Record<string, unknown> | undefined;
|
|
125
|
+
const primaryModelStr = (agentDefaults?.model as Record<string, unknown> | undefined)?.primary as string ?? "";
|
|
126
|
+
const defaultProvider = (agentDefaults?.provider as string) || primaryModelStr.split("/")[0] || "openai";
|
|
127
|
+
const providerConfig = models.providers?.[defaultProvider] as Record<string, unknown> | undefined;
|
|
128
|
+
const originalApi = (providerConfig?.api as string) ?? "openai-completions";
|
|
129
|
+
|
|
130
|
+
// Use openai-completions for the proxy provider: the proxy acts as a transparent
|
|
131
|
+
// HTTP relay and most providers (including Google gateways) accept OpenAI format.
|
|
132
|
+
// For Anthropic-native, we match the API so the SDK sends the right format.
|
|
133
|
+
const proxyApi = resolveProxyApi(originalApi);
|
|
134
|
+
|
|
135
|
+
// Phase 1a: mirror all models explicitly listed in provider configs
|
|
136
|
+
const mirroredModels = mirrorAllProviderModels(
|
|
137
|
+
api.config as { models?: { providers?: Record<string, { models?: unknown }> } },
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
// Phase 1b: also pre-register models referenced by router tier configs
|
|
141
|
+
// (e.g. token-saver tiers) that may not appear in any provider's models list
|
|
142
|
+
const tierModels = collectTierModelIds(resolvedPluginConfig);
|
|
143
|
+
const mirroredIds = new Set(mirroredModels.map((m) => (m as Record<string, unknown>).id));
|
|
144
|
+
for (const { provider: tierProv, modelId: tierModel } of tierModels) {
|
|
145
|
+
if (mirroredIds.has(tierModel)) continue;
|
|
146
|
+
const tierProvConfig = models.providers?.[tierProv] as Record<string, unknown> | undefined;
|
|
147
|
+
const tierProvModels = tierProvConfig?.models;
|
|
148
|
+
let entry: Record<string, unknown> | undefined;
|
|
149
|
+
if (Array.isArray(tierProvModels)) {
|
|
150
|
+
const found = tierProvModels.find((m: unknown) => (m as Record<string, unknown>).id === tierModel);
|
|
151
|
+
if (found) entry = { ...(found as Record<string, unknown>) };
|
|
152
|
+
}
|
|
153
|
+
if (!entry) {
|
|
154
|
+
const firstModel = Array.isArray(tierProvModels) && tierProvModels.length > 0
|
|
155
|
+
? tierProvModels[0] as Record<string, unknown>
|
|
156
|
+
: null;
|
|
157
|
+
entry = {
|
|
158
|
+
id: tierModel,
|
|
159
|
+
name: tierModel,
|
|
160
|
+
...(firstModel?.contextWindow != null ? { contextWindow: firstModel.contextWindow } : {}),
|
|
161
|
+
...(firstModel?.maxTokens != null ? { maxTokens: firstModel.maxTokens } : {}),
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
mirroredModels.push(entry);
|
|
165
|
+
mirroredIds.add(tierModel);
|
|
166
|
+
if (tierProvConfig) {
|
|
167
|
+
registerModelTarget(tierModel, {
|
|
168
|
+
baseUrl: (tierProvConfig.baseUrl as string) ?? resolveDefaultBaseUrl(tierProv, tierProvConfig.api as string | undefined),
|
|
169
|
+
apiKey: (tierProvConfig.apiKey as string) ?? "",
|
|
170
|
+
provider: tierProv,
|
|
171
|
+
api: tierProvConfig.api as string | undefined,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const privacyProviderEntry = {
|
|
177
|
+
baseUrl: `http://127.0.0.1:${proxyPort}/v1`,
|
|
178
|
+
api: proxyApi,
|
|
179
|
+
apiKey: "clawxrouter-proxy-handles-auth",
|
|
180
|
+
models: mirroredModels,
|
|
181
|
+
};
|
|
182
|
+
models.providers["clawxrouter-privacy"] = privacyProviderEntry;
|
|
183
|
+
|
|
184
|
+
// Patch the runtime config snapshot (structuredClone of api.config) so
|
|
185
|
+
// that model resolution inside the embedded agent runner can find the
|
|
186
|
+
// clawxrouter-privacy virtual provider.
|
|
187
|
+
const runtimeLoadConfig = (): Record<string, unknown> | undefined => {
|
|
188
|
+
try { return api.runtime.config.loadConfig(); } catch { return undefined; }
|
|
189
|
+
};
|
|
190
|
+
try {
|
|
191
|
+
const runtimeCfg = runtimeLoadConfig();
|
|
192
|
+
if (runtimeCfg && runtimeCfg !== api.config) {
|
|
193
|
+
if (!runtimeCfg.models) {
|
|
194
|
+
(runtimeCfg as Record<string, unknown>).models = { providers: {} };
|
|
195
|
+
}
|
|
196
|
+
const rtModels = runtimeCfg.models as { providers?: Record<string, unknown> };
|
|
197
|
+
if (!rtModels.providers) rtModels.providers = {};
|
|
198
|
+
rtModels.providers["clawxrouter-privacy"] = privacyProviderEntry;
|
|
199
|
+
}
|
|
200
|
+
} catch {
|
|
201
|
+
// Non-fatal: runtime config patching is best-effort
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Propagate thinking + streaming defaults for all mirrored models.
|
|
205
|
+
// Uses ensureModelMirrored's internal propagateThinkingForModel for
|
|
206
|
+
// reasoning models; streaming propagation is handled separately below.
|
|
207
|
+
for (const m of mirroredModels as Array<Record<string, unknown>>) {
|
|
208
|
+
if (m.reasoning === true && typeof m.id === "string") {
|
|
209
|
+
ensureModelMirrored(
|
|
210
|
+
api.config as Record<string, unknown>,
|
|
211
|
+
m.id as string,
|
|
212
|
+
defaultProvider,
|
|
213
|
+
runtimeLoadConfig,
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Propagate streaming=false for models that have it set in agent defaults
|
|
219
|
+
const existingModelsOverrides = (agentDefaults?.models as Record<string, Record<string, unknown>> | undefined) ?? {};
|
|
220
|
+
for (const [key, override] of Object.entries(existingModelsOverrides)) {
|
|
221
|
+
if (override?.streaming === false) {
|
|
222
|
+
const modelId = key.includes("/") ? key.split("/").slice(1).join("/") : key;
|
|
223
|
+
const proxyKey = `clawxrouter-privacy/${modelId}`;
|
|
224
|
+
if (!existingModelsOverrides[proxyKey]) {
|
|
225
|
+
existingModelsOverrides[proxyKey] = { streaming: false };
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
try {
|
|
230
|
+
const runtimeCfg = runtimeLoadConfig();
|
|
231
|
+
if (runtimeCfg) {
|
|
232
|
+
const rtAgents = (runtimeCfg as Record<string, unknown>).agents as Record<string, unknown> | undefined;
|
|
233
|
+
const rtDefaults = rtAgents?.defaults as Record<string, unknown> | undefined;
|
|
234
|
+
if (rtDefaults) {
|
|
235
|
+
const rtModelsOverrides = (rtDefaults.models ?? {}) as Record<string, Record<string, unknown>>;
|
|
236
|
+
for (const [key, override] of Object.entries(existingModelsOverrides)) {
|
|
237
|
+
if (key.startsWith("clawxrouter-privacy/")) {
|
|
238
|
+
rtModelsOverrides[key] = override;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
rtDefaults.models = rtModelsOverrides;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
} catch {
|
|
245
|
+
// Non-fatal
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Set default provider target for the proxy
|
|
249
|
+
if (providerConfig) {
|
|
250
|
+
const defaultBaseUrl = resolveDefaultBaseUrl(defaultProvider, originalApi);
|
|
251
|
+
const modelsOverrides = (agentDefaults?.models as Record<string, Record<string, unknown>> | undefined) ?? {};
|
|
252
|
+
const modelStreamingPref = modelsOverrides[primaryModelStr]?.streaming;
|
|
253
|
+
setDefaultProviderTarget({
|
|
254
|
+
baseUrl: (providerConfig.baseUrl as string) ?? defaultBaseUrl,
|
|
255
|
+
apiKey: (providerConfig.apiKey as string) ?? "",
|
|
256
|
+
provider: defaultProvider,
|
|
257
|
+
api: originalApi,
|
|
258
|
+
...(modelStreamingPref === false ? { streaming: false } : {}),
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
api.logger.info(`[ClawXrouter] Privacy provider registered (proxy port: ${proxyPort})`);
|
|
263
|
+
|
|
264
|
+
const patchExtraPaths = (cfg: Record<string, unknown>) => {
|
|
265
|
+
const agts = (cfg.agents ?? {}) as Record<string, unknown>;
|
|
266
|
+
const defs = (agts.defaults ?? {}) as Record<string, unknown>;
|
|
267
|
+
const ms = (defs.memorySearch ?? {}) as Record<string, unknown>;
|
|
268
|
+
const existing = (ms.extraPaths ?? []) as string[];
|
|
269
|
+
const requiredPaths = ["MEMORY-FULL.md", "memory-full"];
|
|
270
|
+
const missing = requiredPaths.filter((p) => !existing.includes(p));
|
|
271
|
+
if (missing.length === 0) return false;
|
|
272
|
+
const updated = [...existing, ...missing];
|
|
273
|
+
if (!cfg.agents) cfg.agents = { defaults: {} };
|
|
274
|
+
const a = cfg.agents as Record<string, unknown>;
|
|
275
|
+
if (!a.defaults) a.defaults = {};
|
|
276
|
+
const d = a.defaults as Record<string, unknown>;
|
|
277
|
+
if (!d.memorySearch) d.memorySearch = {};
|
|
278
|
+
(d.memorySearch as Record<string, unknown>).extraPaths = updated;
|
|
279
|
+
return true;
|
|
280
|
+
};
|
|
281
|
+
if (patchExtraPaths(api.config as Record<string, unknown>)) {
|
|
282
|
+
api.logger.info(`[ClawXrouter] Added to memorySearch.extraPaths: MEMORY-FULL.md, memory-full`);
|
|
283
|
+
}
|
|
284
|
+
try {
|
|
285
|
+
const runtimeCfg = api.runtime.config.loadConfig();
|
|
286
|
+
if (runtimeCfg && runtimeCfg !== api.config) {
|
|
287
|
+
patchExtraPaths(runtimeCfg as Record<string, unknown>);
|
|
288
|
+
}
|
|
289
|
+
} catch { /* best-effort */ }
|
|
290
|
+
|
|
291
|
+
let proxyHandle: ProxyHandle | null = null;
|
|
292
|
+
api.registerService({
|
|
293
|
+
id: "clawxrouter-proxy",
|
|
294
|
+
start: async () => {
|
|
295
|
+
try {
|
|
296
|
+
proxyHandle = await startPrivacyProxy(proxyPort, api.logger);
|
|
297
|
+
setActiveProxy(proxyHandle);
|
|
298
|
+
api.logger.info(`[ClawXrouter] Privacy proxy started on port ${proxyPort}`);
|
|
299
|
+
} catch (err) {
|
|
300
|
+
api.logger.error(`[ClawXrouter] Failed to start privacy proxy: ${String(err)}`);
|
|
301
|
+
}
|
|
302
|
+
},
|
|
303
|
+
stop: async () => {
|
|
304
|
+
if (proxyHandle) {
|
|
305
|
+
try {
|
|
306
|
+
await proxyHandle.close();
|
|
307
|
+
api.logger.info("[ClawXrouter] Privacy proxy stopped");
|
|
308
|
+
} catch (err) {
|
|
309
|
+
api.logger.warn(`[ClawXrouter] Failed to close proxy: ${String(err)}`);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
},
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
const pipeline = new RouterPipeline(api.logger);
|
|
316
|
+
|
|
317
|
+
// Register built-in routers
|
|
318
|
+
const routerConfigs = (privacyConfig as Record<string, unknown>).routers as Record<string, RouterRegistration> | undefined;
|
|
319
|
+
pipeline.register(privacyRouter, routerConfigs?.privacy ?? { enabled: true, type: "builtin" });
|
|
320
|
+
pipeline.register(tokenSaverRouter, routerConfigs?.["token-saver"] ?? { enabled: false, type: "builtin" });
|
|
321
|
+
|
|
322
|
+
// Configure pipeline from user config
|
|
323
|
+
pipeline.configure({
|
|
324
|
+
routers: routerConfigs,
|
|
325
|
+
pipeline: (privacyConfig as Record<string, unknown>).pipeline as PipelineConfig | undefined,
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
// Load custom routers (async, non-blocking)
|
|
329
|
+
pipeline.loadCustomRouters().then(() => {
|
|
330
|
+
const routers = pipeline.listRouters();
|
|
331
|
+
if (routers.length > 1) {
|
|
332
|
+
api.logger.info(`[ClawXrouter] Pipeline routers: ${routers.join(", ")}`);
|
|
333
|
+
}
|
|
334
|
+
}).catch((err) => {
|
|
335
|
+
api.logger.error(`[ClawXrouter] Failed to load custom routers: ${String(err)}`);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
setGlobalPipeline(pipeline);
|
|
339
|
+
api.logger.info(`[ClawXrouter] Router pipeline initialized (built-in: privacy)`);
|
|
340
|
+
|
|
341
|
+
initLiveConfig(resolvedPluginConfig);
|
|
342
|
+
watchConfigFile(CLAWXROUTER_CONFIG_PATH, api.logger);
|
|
343
|
+
|
|
344
|
+
const statsPath = join(process.env.HOME ?? "/tmp", ".openclaw", "clawxrouter-stats.json");
|
|
345
|
+
const collector = new TokenStatsCollector(statsPath);
|
|
346
|
+
setGlobalCollector(collector);
|
|
347
|
+
collector.load().then(() => {
|
|
348
|
+
collector.startAutoFlush();
|
|
349
|
+
api.logger.info(`[ClawXrouter] Token stats initialized (${statsPath})`);
|
|
350
|
+
}).catch((err) => {
|
|
351
|
+
api.logger.error(`[ClawXrouter] Failed to load token stats: ${String(err)}`);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
initDashboard({
|
|
355
|
+
pluginId: "clawxrouter",
|
|
356
|
+
pluginConfig: resolvedPluginConfig,
|
|
357
|
+
pipeline,
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
api.registerHttpRoute({
|
|
361
|
+
path: "/plugins/clawxrouter/stats",
|
|
362
|
+
auth: "plugin",
|
|
363
|
+
match: "prefix",
|
|
364
|
+
handler: async (req, res) => {
|
|
365
|
+
const handled = await statsHttpHandler(req, res);
|
|
366
|
+
if (!handled) {
|
|
367
|
+
res.writeHead(404);
|
|
368
|
+
res.end("Not Found");
|
|
369
|
+
}
|
|
370
|
+
},
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
api.logger.info("[ClawXrouter] Dashboard registered at /plugins/clawxrouter/stats");
|
|
374
|
+
|
|
375
|
+
registerHooks(api);
|
|
376
|
+
|
|
377
|
+
api.logger.info("[ClawXrouter] Plugin initialized (pipeline + privacy proxy + guard agent + dashboard)");
|
|
378
|
+
|
|
379
|
+
const c = "\x1b[36m", g = "\x1b[32m", y = "\x1b[33m", b = "\x1b[1m", d = "\x1b[2m", r = "\x1b[0m", bg = "\x1b[46m\x1b[30m";
|
|
380
|
+
const W = 70;
|
|
381
|
+
const bar = "═".repeat(W);
|
|
382
|
+
const pad = (colored: string, visLen: number) => {
|
|
383
|
+
const sp = " ".repeat(Math.max(0, W - visLen));
|
|
384
|
+
return `${c} ║${r}${colored}${sp}${c}║${r}`;
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
api.logger.info("");
|
|
388
|
+
api.logger.info(`${c} ╔${bar}╗${r}`);
|
|
389
|
+
api.logger.info(pad(` ${bg}${b} 🛡️ ClawXrouter ${r}${g}${b} Ready!${r}`, 25));
|
|
390
|
+
api.logger.info(pad("", 0));
|
|
391
|
+
api.logger.info(pad(` ${y}Dashboard${r} ${d}→${r} ${b}http://127.0.0.1:18789/plugins/clawxrouter/stats${r}`, 62));
|
|
392
|
+
api.logger.info(pad(` ${y}Config${r} ${d}→${r} ${b}~/.openclaw/clawxrouter.json${r}`, 40));
|
|
393
|
+
api.logger.info(pad("", 0));
|
|
394
|
+
api.logger.info(pad(` ${d}Use the Dashboard to configure routers, rules & prompts.${r}`, 58));
|
|
395
|
+
api.logger.info(`${c} ╚${bar}╝${r}`);
|
|
396
|
+
api.logger.info("");
|
|
397
|
+
},
|
|
398
|
+
});
|