@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,683 @@
|
|
|
1
|
+
import * as http from "node:http";
|
|
2
|
+
import { redactSensitiveInfo } from "./utils.js";
|
|
3
|
+
import { getLiveConfig } from "./live-config.js";
|
|
4
|
+
import { lookupDesensitizedToolResult } from "./session-state.js";
|
|
5
|
+
|
|
6
|
+
// ── Marker protocol ──
|
|
7
|
+
|
|
8
|
+
export const CLAWXROUTER_S2_OPEN = "<clawxrouter-s2>";
|
|
9
|
+
export const CLAWXROUTER_S2_CLOSE = "</clawxrouter-s2>";
|
|
10
|
+
|
|
11
|
+
// ── Original provider target ──
|
|
12
|
+
|
|
13
|
+
export type OriginalProviderTarget = {
|
|
14
|
+
baseUrl: string;
|
|
15
|
+
apiKey: string;
|
|
16
|
+
provider: string;
|
|
17
|
+
api?: string;
|
|
18
|
+
streaming?: boolean;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
// ── Model-keyed target map ──
|
|
22
|
+
// Deterministic routing: model ID → upstream provider target.
|
|
23
|
+
// Built at init time (mirrorAllProviderModels) and updated JIT
|
|
24
|
+
// (ensureModelMirrored). No per-request header injection needed.
|
|
25
|
+
|
|
26
|
+
const modelProviderTargets = new Map<string, OriginalProviderTarget>();
|
|
27
|
+
|
|
28
|
+
export function registerModelTarget(modelId: string, target: OriginalProviderTarget): void {
|
|
29
|
+
modelProviderTargets.set(modelId, target);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function getModelTarget(modelId: string): OriginalProviderTarget | undefined {
|
|
33
|
+
return modelProviderTargets.get(modelId);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
let defaultProviderTarget: OriginalProviderTarget | null = null;
|
|
37
|
+
|
|
38
|
+
export function setDefaultProviderTarget(target: OriginalProviderTarget): void {
|
|
39
|
+
defaultProviderTarget = target;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ── Proxy handle ──
|
|
43
|
+
|
|
44
|
+
export type ProxyHandle = {
|
|
45
|
+
baseUrl: string;
|
|
46
|
+
port: number;
|
|
47
|
+
close: () => Promise<void>;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// ── Request body reader ──
|
|
51
|
+
|
|
52
|
+
function readRequestBody(req: http.IncomingMessage): Promise<string> {
|
|
53
|
+
return new Promise((resolve, reject) => {
|
|
54
|
+
const chunks: Buffer[] = [];
|
|
55
|
+
req.on("data", (chunk: Buffer) => chunks.push(chunk));
|
|
56
|
+
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
|
|
57
|
+
req.on("error", reject);
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ── Tool schema cleaning ──
|
|
62
|
+
// Multiple provider APIs reject JSON Schema keywords they don't support.
|
|
63
|
+
// Strip these universally so the proxy works regardless of the downstream target.
|
|
64
|
+
|
|
65
|
+
const UNSUPPORTED_SCHEMA_KEYWORDS = new Set([
|
|
66
|
+
"patternProperties",
|
|
67
|
+
"additionalProperties",
|
|
68
|
+
"$schema",
|
|
69
|
+
"$id",
|
|
70
|
+
"$ref",
|
|
71
|
+
"$defs",
|
|
72
|
+
"definitions",
|
|
73
|
+
"examples",
|
|
74
|
+
"minLength",
|
|
75
|
+
"maxLength",
|
|
76
|
+
"minimum",
|
|
77
|
+
"maximum",
|
|
78
|
+
"multipleOf",
|
|
79
|
+
"pattern",
|
|
80
|
+
"format",
|
|
81
|
+
"minItems",
|
|
82
|
+
"maxItems",
|
|
83
|
+
"uniqueItems",
|
|
84
|
+
"minProperties",
|
|
85
|
+
"maxProperties",
|
|
86
|
+
]);
|
|
87
|
+
|
|
88
|
+
function stripUnsupportedSchemaKeywords(obj: unknown): unknown {
|
|
89
|
+
if (!obj || typeof obj !== "object") return obj;
|
|
90
|
+
if (Array.isArray(obj)) return obj.map(stripUnsupportedSchemaKeywords);
|
|
91
|
+
|
|
92
|
+
const cleaned: Record<string, unknown> = {};
|
|
93
|
+
for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
|
|
94
|
+
if (UNSUPPORTED_SCHEMA_KEYWORDS.has(key)) continue;
|
|
95
|
+
if (value && typeof value === "object") {
|
|
96
|
+
cleaned[key] = stripUnsupportedSchemaKeywords(value);
|
|
97
|
+
} else {
|
|
98
|
+
cleaned[key] = value;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return cleaned;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Clean tool parameter schemas in an OpenAI-format request body.
|
|
106
|
+
* Handles `tools[].function.parameters`.
|
|
107
|
+
*/
|
|
108
|
+
export function cleanToolSchemas(
|
|
109
|
+
tools: unknown[] | undefined,
|
|
110
|
+
): boolean {
|
|
111
|
+
if (!Array.isArray(tools) || tools.length === 0) return false;
|
|
112
|
+
let cleaned = false;
|
|
113
|
+
for (let i = 0; i < tools.length; i++) {
|
|
114
|
+
const tool = tools[i] as Record<string, unknown> | undefined;
|
|
115
|
+
if (!tool) continue;
|
|
116
|
+
const fn = tool.function as Record<string, unknown> | undefined;
|
|
117
|
+
const params = fn?.parameters;
|
|
118
|
+
if (params && typeof params === "object") {
|
|
119
|
+
const result = stripUnsupportedSchemaKeywords(params);
|
|
120
|
+
if (result !== params) {
|
|
121
|
+
fn!.parameters = result;
|
|
122
|
+
cleaned = true;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return cleaned;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Clean tool schemas in Google's native format.
|
|
131
|
+
* Handles `tools[].functionDeclarations[].parameters`.
|
|
132
|
+
*/
|
|
133
|
+
export function cleanGoogleToolSchemas(
|
|
134
|
+
tools: unknown[] | undefined,
|
|
135
|
+
): boolean {
|
|
136
|
+
if (!Array.isArray(tools) || tools.length === 0) return false;
|
|
137
|
+
let cleaned = false;
|
|
138
|
+
for (const tool of tools) {
|
|
139
|
+
if (!tool || typeof tool !== "object") continue;
|
|
140
|
+
const decls = (tool as Record<string, unknown>).functionDeclarations ??
|
|
141
|
+
(tool as Record<string, unknown>).function_declarations;
|
|
142
|
+
if (!Array.isArray(decls)) continue;
|
|
143
|
+
for (const decl of decls) {
|
|
144
|
+
if (!decl || typeof decl !== "object") continue;
|
|
145
|
+
const params = (decl as Record<string, unknown>).parameters;
|
|
146
|
+
if (params && typeof params === "object") {
|
|
147
|
+
(decl as Record<string, unknown>).parameters = stripUnsupportedSchemaKeywords(params);
|
|
148
|
+
cleaned = true;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return cleaned;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ── PII marker stripping ──
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Strip PII markers from OpenAI/Anthropic format messages.
|
|
159
|
+
* Format: `messages[].content` (string)
|
|
160
|
+
*/
|
|
161
|
+
export function stripPiiMarkers(
|
|
162
|
+
messages: Array<{ role: string; content: unknown }>,
|
|
163
|
+
): boolean {
|
|
164
|
+
let stripped = false;
|
|
165
|
+
|
|
166
|
+
for (const msg of messages) {
|
|
167
|
+
if (typeof msg.content === "string") {
|
|
168
|
+
const openIdx = msg.content.indexOf(CLAWXROUTER_S2_OPEN);
|
|
169
|
+
const closeIdx = msg.content.indexOf(CLAWXROUTER_S2_CLOSE);
|
|
170
|
+
if (openIdx === -1 || closeIdx === -1 || closeIdx <= openIdx) continue;
|
|
171
|
+
msg.content = msg.content
|
|
172
|
+
.slice(openIdx + CLAWXROUTER_S2_OPEN.length, closeIdx)
|
|
173
|
+
.trim();
|
|
174
|
+
stripped = true;
|
|
175
|
+
} else if (Array.isArray(msg.content)) {
|
|
176
|
+
for (const part of msg.content as Array<Record<string, unknown>>) {
|
|
177
|
+
if (!part || typeof part.text !== "string") continue;
|
|
178
|
+
const openIdx = part.text.indexOf(CLAWXROUTER_S2_OPEN);
|
|
179
|
+
const closeIdx = part.text.indexOf(CLAWXROUTER_S2_CLOSE);
|
|
180
|
+
if (openIdx === -1 || closeIdx === -1 || closeIdx <= openIdx) continue;
|
|
181
|
+
part.text = part.text
|
|
182
|
+
.slice(openIdx + CLAWXROUTER_S2_OPEN.length, closeIdx)
|
|
183
|
+
.trim();
|
|
184
|
+
stripped = true;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return stripped;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Strip PII markers from Google Gemini native format.
|
|
194
|
+
* Format: `contents[].parts[].text` (string)
|
|
195
|
+
*/
|
|
196
|
+
export function stripPiiMarkersGoogleContents(
|
|
197
|
+
contents: unknown[] | undefined,
|
|
198
|
+
): boolean {
|
|
199
|
+
if (!Array.isArray(contents) || contents.length === 0) return false;
|
|
200
|
+
let stripped = false;
|
|
201
|
+
|
|
202
|
+
for (const entry of contents) {
|
|
203
|
+
if (!entry || typeof entry !== "object") continue;
|
|
204
|
+
const e = entry as Record<string, unknown>;
|
|
205
|
+
const parts = e.parts;
|
|
206
|
+
if (!Array.isArray(parts)) continue;
|
|
207
|
+
|
|
208
|
+
for (const part of parts) {
|
|
209
|
+
if (!part || typeof part !== "object") continue;
|
|
210
|
+
const p = part as Record<string, unknown>;
|
|
211
|
+
if (typeof p.text !== "string") continue;
|
|
212
|
+
|
|
213
|
+
const openIdx = p.text.indexOf(CLAWXROUTER_S2_OPEN);
|
|
214
|
+
const closeIdx = p.text.indexOf(CLAWXROUTER_S2_CLOSE);
|
|
215
|
+
if (openIdx === -1 || closeIdx === -1 || closeIdx <= openIdx) continue;
|
|
216
|
+
|
|
217
|
+
p.text = p.text
|
|
218
|
+
.slice(openIdx + CLAWXROUTER_S2_OPEN.length, closeIdx)
|
|
219
|
+
.trim();
|
|
220
|
+
stripped = true;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return stripped;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ── Provider-aware auth headers ──
|
|
228
|
+
|
|
229
|
+
const ANTHROPIC_PATTERNS = ["anthropic"];
|
|
230
|
+
const ANTHROPIC_APIS = ["anthropic-messages"];
|
|
231
|
+
|
|
232
|
+
const GOOGLE_NATIVE_APIS = ["google-generative-ai", "google-gemini-cli", "google-ai-studio"];
|
|
233
|
+
const GOOGLE_URL_MARKERS = ["generativelanguage.googleapis.com", "aiplatform.googleapis.com"];
|
|
234
|
+
|
|
235
|
+
export function isGoogleTarget(target: OriginalProviderTarget): boolean {
|
|
236
|
+
const api = (target.api ?? "").toLowerCase();
|
|
237
|
+
const provider = target.provider.toLowerCase();
|
|
238
|
+
const url = target.baseUrl.toLowerCase();
|
|
239
|
+
|
|
240
|
+
if (api === "openai-completions" || api === "openai-chat") return false;
|
|
241
|
+
if (GOOGLE_NATIVE_APIS.some((p) => api.includes(p))) return true;
|
|
242
|
+
if (provider === "google" || provider.includes("gemini") || provider.includes("vertex")) return true;
|
|
243
|
+
if (GOOGLE_URL_MARKERS.some((p) => url.includes(p))) return true;
|
|
244
|
+
return false;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export function resolveAuthHeaders(target: OriginalProviderTarget): Record<string, string> {
|
|
248
|
+
const headers: Record<string, string> = {};
|
|
249
|
+
if (!target.apiKey) return headers;
|
|
250
|
+
|
|
251
|
+
const p = target.provider.toLowerCase();
|
|
252
|
+
const api = (target.api ?? "").toLowerCase();
|
|
253
|
+
|
|
254
|
+
if (ANTHROPIC_PATTERNS.some((pat) => p.includes(pat)) || ANTHROPIC_APIS.includes(api)) {
|
|
255
|
+
headers["x-api-key"] = target.apiKey;
|
|
256
|
+
headers["anthropic-version"] = "2023-06-01";
|
|
257
|
+
} else {
|
|
258
|
+
headers["Authorization"] = `Bearer ${target.apiKey}`;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return headers;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ── Resolve original provider target ──
|
|
265
|
+
|
|
266
|
+
function resolveTarget(modelId: string | undefined): OriginalProviderTarget | null {
|
|
267
|
+
if (modelId) {
|
|
268
|
+
const t = modelProviderTargets.get(modelId);
|
|
269
|
+
if (t) return t;
|
|
270
|
+
}
|
|
271
|
+
return defaultProviderTarget;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ── SSE conversion for non-streaming upstreams ──
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Convert a complete (non-streaming) OpenAI response into SSE chunks
|
|
278
|
+
* that the SDK can parse as a streaming response.
|
|
279
|
+
*/
|
|
280
|
+
function completionToSSE(responseJson: Record<string, unknown>): string {
|
|
281
|
+
const id = (responseJson.id as string) ?? "chatcmpl-proxy";
|
|
282
|
+
const model = (responseJson.model as string) ?? "";
|
|
283
|
+
const created = (responseJson.created as number) ?? Math.floor(Date.now() / 1000);
|
|
284
|
+
const choices = (responseJson.choices as Array<Record<string, unknown>>) ?? [];
|
|
285
|
+
|
|
286
|
+
const chunks: string[] = [];
|
|
287
|
+
|
|
288
|
+
for (const choice of choices) {
|
|
289
|
+
const msg = choice.message as Record<string, unknown> | undefined;
|
|
290
|
+
const content = (msg?.content as string) ?? "";
|
|
291
|
+
const finishReason = (choice.finish_reason as string) ?? "stop";
|
|
292
|
+
|
|
293
|
+
// Content chunk
|
|
294
|
+
if (content) {
|
|
295
|
+
chunks.push(`data: ${JSON.stringify({
|
|
296
|
+
id,
|
|
297
|
+
object: "chat.completion.chunk",
|
|
298
|
+
created,
|
|
299
|
+
model,
|
|
300
|
+
choices: [{ index: choice.index ?? 0, delta: { role: "assistant", content }, finish_reason: null }],
|
|
301
|
+
})}\n\n`);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Finish chunk
|
|
305
|
+
chunks.push(`data: ${JSON.stringify({
|
|
306
|
+
id,
|
|
307
|
+
object: "chat.completion.chunk",
|
|
308
|
+
created,
|
|
309
|
+
model,
|
|
310
|
+
choices: [{ index: choice.index ?? 0, delta: {}, finish_reason: finishReason }],
|
|
311
|
+
...(responseJson.usage ? { usage: responseJson.usage } : {}),
|
|
312
|
+
})}\n\n`);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
chunks.push("data: [DONE]\n\n");
|
|
316
|
+
return chunks.join("");
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// ── Upstream URL construction ──
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Build the upstream URL by combining the target's baseUrl with the
|
|
323
|
+
* incoming request path. The proxy is mounted at /v1, so we strip that
|
|
324
|
+
* prefix and append the remainder to the target baseUrl.
|
|
325
|
+
*
|
|
326
|
+
* For Google providers using native APIs (google-generative-ai, etc.),
|
|
327
|
+
* the OpenAI-compatible endpoint lives under `/openai/` on the same host.
|
|
328
|
+
* We insert that segment so the proxy can forward OpenAI-format requests.
|
|
329
|
+
*
|
|
330
|
+
* Example:
|
|
331
|
+
* req.url = "/v1/chat/completions"
|
|
332
|
+
* target.baseUrl = "https://api.openai.com/v1"
|
|
333
|
+
* → "https://api.openai.com/v1/chat/completions"
|
|
334
|
+
*
|
|
335
|
+
* target.baseUrl = "https://generativelanguage.googleapis.com/v1beta"
|
|
336
|
+
* target = Google provider
|
|
337
|
+
* → "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions"
|
|
338
|
+
*/
|
|
339
|
+
export function buildUpstreamUrl(targetBaseUrl: string, reqUrl: string | undefined, target?: OriginalProviderTarget): string {
|
|
340
|
+
let baseUrl = targetBaseUrl.replace(/\/+$/, "");
|
|
341
|
+
const forwardPath = (reqUrl ?? "/v1/chat/completions").replace(/^\/v1/, "");
|
|
342
|
+
|
|
343
|
+
if (target && isGoogleTarget(target) && !baseUrl.includes("/openai")) {
|
|
344
|
+
baseUrl = `${baseUrl}/openai`;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
return `${baseUrl}${forwardPath}`;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// ── Streaming with timeout fallback ──
|
|
351
|
+
|
|
352
|
+
const STREAM_FIRST_CHUNK_TIMEOUT_MS = 30_000;
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Attempt to forward a streaming request to the upstream.
|
|
356
|
+
* Returns true if streaming succeeded (response fully piped), false if
|
|
357
|
+
* the upstream didn't send any data within the timeout — caller should
|
|
358
|
+
* fall back to non-streaming.
|
|
359
|
+
*/
|
|
360
|
+
async function tryStreamUpstream(
|
|
361
|
+
parsed: Record<string, unknown>,
|
|
362
|
+
upstreamUrl: string,
|
|
363
|
+
upstreamHeaders: Record<string, string>,
|
|
364
|
+
res: import("node:http").ServerResponse,
|
|
365
|
+
log: { info: (msg: string) => void; warn: (msg: string) => void; error: (msg: string) => void },
|
|
366
|
+
): Promise<boolean> {
|
|
367
|
+
const controller = new AbortController();
|
|
368
|
+
const timeout = setTimeout(() => controller.abort(), STREAM_FIRST_CHUNK_TIMEOUT_MS);
|
|
369
|
+
|
|
370
|
+
let upstream: Response;
|
|
371
|
+
try {
|
|
372
|
+
upstream = await fetch(upstreamUrl, {
|
|
373
|
+
method: "POST",
|
|
374
|
+
headers: upstreamHeaders,
|
|
375
|
+
body: JSON.stringify(parsed),
|
|
376
|
+
signal: controller.signal,
|
|
377
|
+
});
|
|
378
|
+
} catch {
|
|
379
|
+
clearTimeout(timeout);
|
|
380
|
+
return false;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if (!upstream.body || !upstream.ok) {
|
|
384
|
+
clearTimeout(timeout);
|
|
385
|
+
return false;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const reader = (upstream.body as ReadableStream<Uint8Array>).getReader();
|
|
389
|
+
|
|
390
|
+
// Wait for the first chunk within the timeout
|
|
391
|
+
let firstRead: ReadableStreamReadResult<Uint8Array>;
|
|
392
|
+
try {
|
|
393
|
+
firstRead = await reader.read();
|
|
394
|
+
} catch {
|
|
395
|
+
clearTimeout(timeout);
|
|
396
|
+
try { reader.releaseLock(); } catch { /* ignore */ }
|
|
397
|
+
return false;
|
|
398
|
+
}
|
|
399
|
+
clearTimeout(timeout);
|
|
400
|
+
|
|
401
|
+
if (firstRead.done) {
|
|
402
|
+
return false;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// Streaming is working — send headers and pipe
|
|
406
|
+
const contentType = upstream.headers.get("content-type") ?? "text/event-stream";
|
|
407
|
+
res.writeHead(upstream.status, {
|
|
408
|
+
"Content-Type": contentType,
|
|
409
|
+
"Cache-Control": "no-cache",
|
|
410
|
+
"Connection": "keep-alive",
|
|
411
|
+
});
|
|
412
|
+
res.write(Buffer.from(firstRead.value));
|
|
413
|
+
|
|
414
|
+
try {
|
|
415
|
+
while (true) {
|
|
416
|
+
const { done, value } = await reader.read();
|
|
417
|
+
if (done) break;
|
|
418
|
+
if (!res.writableEnded) {
|
|
419
|
+
res.write(Buffer.from(value));
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
} catch {
|
|
423
|
+
log.warn("[ClawXrouter Proxy] Upstream stream closed unexpectedly");
|
|
424
|
+
} finally {
|
|
425
|
+
if (!res.writableEnded) res.end();
|
|
426
|
+
}
|
|
427
|
+
return true;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// ── Proxy server ──
|
|
431
|
+
|
|
432
|
+
export async function startPrivacyProxy(
|
|
433
|
+
port: number,
|
|
434
|
+
logger?: { info: (msg: string) => void; warn: (msg: string) => void; error: (msg: string) => void },
|
|
435
|
+
): Promise<ProxyHandle> {
|
|
436
|
+
const log = logger ?? {
|
|
437
|
+
info: (m: string) => console.log(m),
|
|
438
|
+
warn: (m: string) => console.warn(m),
|
|
439
|
+
error: (m: string) => console.error(m),
|
|
440
|
+
};
|
|
441
|
+
|
|
442
|
+
const server = http.createServer(async (req, res) => {
|
|
443
|
+
if (req.method !== "POST") {
|
|
444
|
+
res.writeHead(405, { "Content-Type": "application/json" });
|
|
445
|
+
res.end(JSON.stringify({ error: "Method not allowed" }));
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
try {
|
|
450
|
+
log.info(`[ClawXrouter Proxy] Incoming ${req.method} ${req.url}`);
|
|
451
|
+
const body = await readRequestBody(req);
|
|
452
|
+
const parsed = JSON.parse(body);
|
|
453
|
+
|
|
454
|
+
// Step 1: Strip PII markers (supports both OpenAI and Google formats)
|
|
455
|
+
const hadOpenAiMarkers = stripPiiMarkers(parsed.messages ?? []);
|
|
456
|
+
const hadGoogleMarkers = stripPiiMarkersGoogleContents(parsed.contents);
|
|
457
|
+
if (hadOpenAiMarkers || hadGoogleMarkers) {
|
|
458
|
+
log.info("[ClawXrouter Proxy] Stripped S2 PII markers from request");
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// Step 2: Clean tool schemas (supports both OpenAI and Google formats)
|
|
462
|
+
const hadOpenAiSchemaFix = cleanToolSchemas(parsed.tools);
|
|
463
|
+
const hadGoogleSchemaFix = cleanGoogleToolSchemas(parsed.tools);
|
|
464
|
+
if (hadOpenAiSchemaFix || hadGoogleSchemaFix) {
|
|
465
|
+
log.info("[ClawXrouter Proxy] Cleaned unsupported keywords from tool schemas");
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// Step 2b: Defense-in-depth — PII redaction on all non-system messages.
|
|
469
|
+
//
|
|
470
|
+
// Two layers:
|
|
471
|
+
// (a) Cached LLM desensitization: tool_result_persist stashed a
|
|
472
|
+
// semantically desensitized version (covers names, addresses, etc.)
|
|
473
|
+
// (b) Rule-based regex redaction: catches structured PII patterns
|
|
474
|
+
// (phone, email, SSN, etc.) as a universal fallback.
|
|
475
|
+
//
|
|
476
|
+
// System messages are excluded to avoid corrupting security instructions.
|
|
477
|
+
const redactionOpts = getLiveConfig().redaction;
|
|
478
|
+
const allMessages = (parsed.messages ?? parsed.contents ?? []) as Array<Record<string, unknown>>;
|
|
479
|
+
for (const msg of allMessages) {
|
|
480
|
+
const role = String(msg.role ?? "").toLowerCase();
|
|
481
|
+
if (role === "system") continue;
|
|
482
|
+
|
|
483
|
+
// (a) Try cached LLM-desensitized version from tool_result_persist
|
|
484
|
+
if (typeof msg.content === "string") {
|
|
485
|
+
const cached = lookupDesensitizedToolResult(msg.content);
|
|
486
|
+
if (cached) {
|
|
487
|
+
msg.content = cached;
|
|
488
|
+
log.info("[ClawXrouter Proxy] Applied cached LLM-desensitized tool result");
|
|
489
|
+
continue;
|
|
490
|
+
}
|
|
491
|
+
} else if (Array.isArray(msg.content)) {
|
|
492
|
+
let cacheHit = false;
|
|
493
|
+
for (const part of msg.content as Array<Record<string, unknown>>) {
|
|
494
|
+
if (part && typeof part.text === "string") {
|
|
495
|
+
const cached = lookupDesensitizedToolResult(part.text);
|
|
496
|
+
if (cached) {
|
|
497
|
+
part.text = cached;
|
|
498
|
+
cacheHit = true;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
if (cacheHit) {
|
|
503
|
+
log.info("[ClawXrouter Proxy] Applied cached LLM-desensitized tool result (array content)");
|
|
504
|
+
continue;
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
if (Array.isArray(msg.parts)) {
|
|
508
|
+
let cacheHit = false;
|
|
509
|
+
for (const part of msg.parts as Array<Record<string, unknown>>) {
|
|
510
|
+
if (part && typeof part.text === "string") {
|
|
511
|
+
const cached = lookupDesensitizedToolResult(part.text);
|
|
512
|
+
if (cached) {
|
|
513
|
+
part.text = cached;
|
|
514
|
+
cacheHit = true;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
if (cacheHit) {
|
|
519
|
+
log.info("[ClawXrouter Proxy] Applied cached LLM-desensitized tool result (Google parts)");
|
|
520
|
+
continue;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// (b) Regex-based PII redaction fallback
|
|
525
|
+
if (typeof msg.content === "string") {
|
|
526
|
+
const redacted = redactSensitiveInfo(msg.content, redactionOpts);
|
|
527
|
+
if (redacted !== msg.content) {
|
|
528
|
+
msg.content = redacted;
|
|
529
|
+
log.info("[ClawXrouter Proxy] Defense-in-depth: rule-based PII redaction applied to message");
|
|
530
|
+
}
|
|
531
|
+
} else if (Array.isArray(msg.content)) {
|
|
532
|
+
for (const part of msg.content as Array<Record<string, unknown>>) {
|
|
533
|
+
if (part && typeof part.text === "string") {
|
|
534
|
+
const redacted = redactSensitiveInfo(part.text, redactionOpts);
|
|
535
|
+
if (redacted !== part.text) {
|
|
536
|
+
part.text = redacted;
|
|
537
|
+
log.info("[ClawXrouter Proxy] Defense-in-depth: rule-based PII redaction applied to message part");
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
if (Array.isArray(msg.parts)) {
|
|
543
|
+
for (const part of msg.parts as Array<Record<string, unknown>>) {
|
|
544
|
+
if (part && typeof part.text === "string") {
|
|
545
|
+
const redacted = redactSensitiveInfo(part.text, redactionOpts);
|
|
546
|
+
if (redacted !== part.text) {
|
|
547
|
+
part.text = redacted;
|
|
548
|
+
log.info("[ClawXrouter Proxy] Defense-in-depth: rule-based PII redaction applied to Google part");
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// Step 3: Resolve the upstream provider via model-keyed target map
|
|
556
|
+
const requestModel = parsed.model as string | undefined;
|
|
557
|
+
const target = resolveTarget(requestModel);
|
|
558
|
+
|
|
559
|
+
if (!target) {
|
|
560
|
+
log.error("[ClawXrouter Proxy] No original provider target found");
|
|
561
|
+
res.writeHead(502, { "Content-Type": "application/json" });
|
|
562
|
+
res.end(JSON.stringify({
|
|
563
|
+
error: {
|
|
564
|
+
message: "ClawXrouter privacy proxy: no original provider target configured",
|
|
565
|
+
type: "proxy_error",
|
|
566
|
+
},
|
|
567
|
+
}));
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// Step 4: Build upstream URL (transparent path forwarding)
|
|
572
|
+
const upstreamUrl = buildUpstreamUrl(target.baseUrl, req.url, target);
|
|
573
|
+
|
|
574
|
+
// Step 5: Forward cleaned request with provider-aware auth
|
|
575
|
+
const upstreamHeaders: Record<string, string> = {
|
|
576
|
+
"Content-Type": "application/json",
|
|
577
|
+
...resolveAuthHeaders(target),
|
|
578
|
+
};
|
|
579
|
+
|
|
580
|
+
// Cap max_tokens for S2 traffic to avoid upstream rejections on
|
|
581
|
+
// desensitized (shorter) content. S1 traffic passes through uncapped.
|
|
582
|
+
const hasS2Markers = hadOpenAiMarkers || hadGoogleMarkers;
|
|
583
|
+
if (hasS2Markers) {
|
|
584
|
+
const MAX_COMPLETION_TOKENS = 16384;
|
|
585
|
+
for (const key of ["max_tokens", "max_completion_tokens"] as const) {
|
|
586
|
+
if (parsed[key] != null && (parsed[key] as number) > MAX_COMPLETION_TOKENS) {
|
|
587
|
+
log.info(`[ClawXrouter Proxy] Capped ${key} ${parsed[key]} → ${MAX_COMPLETION_TOKENS}`);
|
|
588
|
+
parsed[key] = MAX_COMPLETION_TOKENS;
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
const clientWantsStream = !!parsed.stream;
|
|
594
|
+
log.info(`[ClawXrouter Proxy] → ${upstreamUrl} (stream=${clientWantsStream})`);
|
|
595
|
+
|
|
596
|
+
if (clientWantsStream) {
|
|
597
|
+
const streamOk = await tryStreamUpstream(parsed, upstreamUrl, upstreamHeaders, res, log);
|
|
598
|
+
if (streamOk) return;
|
|
599
|
+
log.info("[ClawXrouter Proxy] Streaming unavailable, falling back to non-streaming + SSE conversion");
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// Non-streaming upstream request (or fallback from failed stream).
|
|
603
|
+
const upstreamBody = { ...parsed, stream: false };
|
|
604
|
+
const nonStreamController = new AbortController();
|
|
605
|
+
const nonStreamTimeout = setTimeout(() => nonStreamController.abort(), 120_000);
|
|
606
|
+
let upstream: Response;
|
|
607
|
+
try {
|
|
608
|
+
upstream = await fetch(upstreamUrl, {
|
|
609
|
+
method: "POST",
|
|
610
|
+
headers: upstreamHeaders,
|
|
611
|
+
body: JSON.stringify(upstreamBody),
|
|
612
|
+
signal: nonStreamController.signal,
|
|
613
|
+
});
|
|
614
|
+
} catch (fetchErr) {
|
|
615
|
+
clearTimeout(nonStreamTimeout);
|
|
616
|
+
const msg = fetchErr instanceof Error && fetchErr.name === "AbortError"
|
|
617
|
+
? "Upstream request timed out (120s)"
|
|
618
|
+
: String(fetchErr);
|
|
619
|
+
log.error(`[ClawXrouter Proxy] Upstream fetch failed: ${msg}`);
|
|
620
|
+
res.writeHead(504, { "Content-Type": "application/json" });
|
|
621
|
+
res.end(JSON.stringify({ error: { message: msg, type: "proxy_timeout" } }));
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
clearTimeout(nonStreamTimeout);
|
|
625
|
+
|
|
626
|
+
if (clientWantsStream) {
|
|
627
|
+
const responseJson = await upstream.json() as Record<string, unknown>;
|
|
628
|
+
log.info(`[ClawXrouter Proxy] Upstream responded: status=${upstream.status} ok=${upstream.ok}`);
|
|
629
|
+
if (upstream.ok) {
|
|
630
|
+
const ssePayload = completionToSSE(responseJson);
|
|
631
|
+
res.writeHead(200, {
|
|
632
|
+
"Content-Type": "text/event-stream",
|
|
633
|
+
"Cache-Control": "no-cache",
|
|
634
|
+
"Connection": "keep-alive",
|
|
635
|
+
});
|
|
636
|
+
res.end(ssePayload);
|
|
637
|
+
} else {
|
|
638
|
+
res.writeHead(upstream.status, { "Content-Type": "application/json" });
|
|
639
|
+
res.end(JSON.stringify(responseJson));
|
|
640
|
+
}
|
|
641
|
+
} else {
|
|
642
|
+
const contentType = upstream.headers.get("content-type") ?? "application/json";
|
|
643
|
+
res.writeHead(upstream.status, { "Content-Type": contentType });
|
|
644
|
+
const responseBody = await upstream.text();
|
|
645
|
+
res.end(responseBody);
|
|
646
|
+
}
|
|
647
|
+
} catch (err) {
|
|
648
|
+
log.error(`[ClawXrouter Proxy] Request failed: ${String(err)}`);
|
|
649
|
+
if (!res.headersSent) {
|
|
650
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
651
|
+
}
|
|
652
|
+
if (!res.writableEnded) {
|
|
653
|
+
res.end(JSON.stringify({
|
|
654
|
+
error: {
|
|
655
|
+
message: `ClawXrouter proxy error: ${String(err)}`,
|
|
656
|
+
type: "proxy_error",
|
|
657
|
+
},
|
|
658
|
+
}));
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
// Handle server-level errors
|
|
664
|
+
server.on("error", (err) => {
|
|
665
|
+
log.error(`[ClawXrouter Proxy] Server error: ${String(err)}`);
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
return new Promise<ProxyHandle>((resolve, reject) => {
|
|
669
|
+
server.listen(port, "127.0.0.1", () => {
|
|
670
|
+
resolve({
|
|
671
|
+
baseUrl: `http://127.0.0.1:${port}`,
|
|
672
|
+
port,
|
|
673
|
+
close: () =>
|
|
674
|
+
new Promise<void>((r) => {
|
|
675
|
+
server.close(() => r());
|
|
676
|
+
// Force-close lingering connections after a short grace period
|
|
677
|
+
setTimeout(() => r(), 2000);
|
|
678
|
+
}),
|
|
679
|
+
});
|
|
680
|
+
});
|
|
681
|
+
server.on("error", reject);
|
|
682
|
+
});
|
|
683
|
+
}
|