@openbmb/clawxrouter 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
+ }