clawmatrix 0.1.23 → 0.2.0

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.
@@ -11,7 +11,7 @@ import type {
11
11
  import { debug } from "./debug.ts";
12
12
  import { readBody } from "./http-utils.ts";
13
13
 
14
- const MODEL_TIMEOUT = 120_000; // 2 minutes
14
+ const DEFAULT_MODEL_TIMEOUT = 120_000; // 2 minutes
15
15
 
16
16
  /** Normalize usage from OpenAI-compatible APIs (supports both field naming conventions). */
17
17
  function parseUsage(usage: Record<string, number> | undefined): { inputTokens: number; outputTokens: number } | undefined {
@@ -62,17 +62,54 @@ export class ModelProxy {
62
62
  private httpServer: Server | null = null;
63
63
  private gatewayInfo: GatewayInfo;
64
64
  private openclawConfig: OpenClawConfig;
65
+ private readonly modelTimeout: number;
66
+
67
+ /** Dynamically discovered proxy models from peer capabilities (auto-discovery). */
68
+ private discoveredModels: import("./config.ts").ProxyModel[] = [];
65
69
 
66
70
  /** Cache of models that need a different API format than configured (detected at runtime).
67
71
  * Entries expire after 10 minutes so upstream upgrades are eventually detected. */
68
72
  private modelApiCache = new Map<string, { api: string; ts: number }>();
69
73
  private static readonly MODEL_API_CACHE_TTL = 600_000; // 10 minutes
74
+ private cacheCleanupTimer: ReturnType<typeof setInterval> | null = null;
70
75
 
71
76
  constructor(config: ClawMatrixConfig, peerManager: PeerManager, gatewayInfo: GatewayInfo, openclawConfig: OpenClawConfig) {
72
77
  this.config = config;
73
78
  this.peerManager = peerManager;
74
79
  this.gatewayInfo = gatewayInfo;
75
80
  this.openclawConfig = openclawConfig;
81
+ this.modelTimeout = config.modelTimeout ?? DEFAULT_MODEL_TIMEOUT;
82
+ }
83
+
84
+ /** All proxy models: static config + dynamically discovered from peers. */
85
+ get allProxyModels(): import("./config.ts").ProxyModel[] {
86
+ if (this.discoveredModels.length === 0) return this.config.proxyModels;
87
+ return [...this.config.proxyModels, ...this.discoveredModels];
88
+ }
89
+
90
+ /** Rebuild discovered models from current peer capabilities.
91
+ * Skips models already present in static config.proxyModels. */
92
+ updateDiscoveredModels(peers: import("./router.ts").RouteEntry[]) {
93
+ const staticKeys = new Set(this.config.proxyModels.map((m) => `${m.nodeId}/${m.id}`));
94
+ const next: import("./config.ts").ProxyModel[] = [];
95
+ for (const peer of peers) {
96
+ for (const m of peer.models) {
97
+ const key = `${peer.nodeId}/${m.id}`;
98
+ if (staticKeys.has(key)) continue;
99
+ next.push({
100
+ id: m.id,
101
+ nodeId: peer.nodeId,
102
+ provider: m.provider,
103
+ description: m.description,
104
+ input: m.input,
105
+ compat: m.compat as import("./config.ts").ProxyModel["compat"],
106
+ });
107
+ }
108
+ }
109
+ this.discoveredModels = next;
110
+ if (next.length > 0) {
111
+ debug("proxy", `Auto-discovered ${next.length} model(s) from peers: ${next.map((m) => `${m.nodeId}/${m.id}`).join(", ")}`);
112
+ }
76
113
  }
77
114
 
78
115
  /**
@@ -226,8 +263,18 @@ export class ModelProxy {
226
263
  return items;
227
264
  }
228
265
 
229
- /** Resolve API endpoint for a model: explicit config > OpenClaw provider > gateway fallback */
230
- private resolveModelEndpoint(model: { id: string; provider: string; baseUrl?: string; apiKey?: string; api?: string }): { baseUrl: string; apiKey?: string; direct: boolean; api: string } {
266
+ /**
267
+ * Resolve API endpoint for a model: explicit config > OpenClaw provider > null.
268
+ *
269
+ * ⚠️ 重要:绝对不能 fallback 到 OpenClaw gateway 的 /v1/chat/completions!
270
+ * OpenClaw gateway 的 /v1/chat/completions 会走 Agent 系统,每次请求都会创建
271
+ * 一个新的 Agent session(带记忆、system prompt 等)。这会导致:
272
+ * - 远程节点(如 iPhone)的每次 model_req 都在本地产生一个多余的 OpenClaw 会话
273
+ * - 模型响应被 OpenClaw Agent 的 system prompt 和记忆污染,结果不正确
274
+ *
275
+ * 如果找不到直连 API 端点,必须返回 null 让调用方报错,而不是静默 fallback。
276
+ */
277
+ private resolveModelEndpoint(model: { id: string; provider: string; baseUrl?: string; apiKey?: string; api?: string }): { baseUrl: string; apiKey?: string; direct: boolean; api: string } | null {
231
278
  const defaultApi = "openai-completions";
232
279
 
233
280
  // 1. Explicit baseUrl in ClawMatrix model config
@@ -255,18 +302,20 @@ export class ModelProxy {
255
302
  };
256
303
  }
257
304
 
258
- // 3. Fallback: OpenClaw gateway
259
- const { port } = this.gatewayInfo;
260
- return {
261
- baseUrl: `http://127.0.0.1:${port}/v1`,
262
- apiKey: undefined,
263
- direct: false,
264
- api: model.api ?? defaultApi,
265
- };
305
+ // 找不到直连端点 返回 null(见上方注释,不能 fallback 到 gateway
306
+ return null;
266
307
  }
267
308
 
268
309
  /** Start the local HTTP proxy server for OpenAI-compatible requests. */
269
310
  start() {
311
+ // Periodically prune expired model API cache entries
312
+ this.cacheCleanupTimer = setInterval(() => {
313
+ const now = Date.now();
314
+ for (const [id, entry] of this.modelApiCache) {
315
+ if (now - entry.ts > ModelProxy.MODEL_API_CACHE_TTL) this.modelApiCache.delete(id);
316
+ }
317
+ }, ModelProxy.MODEL_API_CACHE_TTL);
318
+
270
319
  this.httpServer = createServer(async (req, res) => {
271
320
  try {
272
321
  const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
@@ -308,6 +357,10 @@ export class ModelProxy {
308
357
  }
309
358
 
310
359
  stop() {
360
+ if (this.cacheCleanupTimer) {
361
+ clearInterval(this.cacheCleanupTimer);
362
+ this.cacheCleanupTimer = null;
363
+ }
311
364
  if (this.httpServer) {
312
365
  this.httpServer.close();
313
366
  this.httpServer = null;
@@ -338,11 +391,22 @@ export class ModelProxy {
338
391
  } else {
339
392
  // Stream response
340
393
  const reader = response.body.getReader();
394
+ let finished = false;
395
+
396
+ // Clean up stream when client disconnects mid-stream
397
+ res.on("close", () => {
398
+ if (!finished) {
399
+ finished = true;
400
+ reader.cancel().catch(() => {});
401
+ }
402
+ });
403
+
341
404
  const pump = (): void => {
342
405
  reader.read().then(({ done, value }) => {
343
- if (done) {
406
+ if (done || finished) {
407
+ finished = true;
344
408
  reader.releaseLock();
345
- res.end();
409
+ if (!res.writableEnded) res.end();
346
410
  return;
347
411
  }
348
412
  const ok = res.write(value);
@@ -352,8 +416,9 @@ export class ModelProxy {
352
416
  res.once("drain", pump);
353
417
  }
354
418
  }).catch(() => {
419
+ finished = true;
355
420
  reader.releaseLock();
356
- res.end();
421
+ if (!res.writableEnded) res.end();
357
422
  });
358
423
  };
359
424
  pump();
@@ -369,16 +434,17 @@ export class ModelProxy {
369
434
  } | { error: { status: number; message: string } } {
370
435
  const slashIdx = rawModelId.indexOf("/");
371
436
  let modelId: string;
372
- let matchingModels: (typeof this.config.proxyModels)[number][];
437
+ const proxyModels = this.allProxyModels;
438
+ let matchingModels: (typeof proxyModels)[number][];
373
439
 
374
440
  if (slashIdx > 0) {
375
441
  const nodeId = rawModelId.slice(0, slashIdx);
376
442
  modelId = rawModelId.slice(slashIdx + 1);
377
443
  // Explicit node/model — only target that specific node, no failover to others
378
- matchingModels = this.config.proxyModels.filter((m) => m.id === modelId && m.nodeId === nodeId);
444
+ matchingModels = proxyModels.filter((m) => m.id === modelId && m.nodeId === nodeId);
379
445
  } else {
380
446
  modelId = rawModelId;
381
- matchingModels = this.config.proxyModels.filter((m) => m.id === modelId);
447
+ matchingModels = proxyModels.filter((m) => m.id === modelId);
382
448
  }
383
449
 
384
450
  if (matchingModels.length === 0) {
@@ -397,6 +463,16 @@ export class ModelProxy {
397
463
  }
398
464
  }
399
465
 
466
+ // Sort candidates by latency (lowest first) for optimal first-try and failover order
467
+ candidates.sort((a, b) => {
468
+ const routeA = this.peerManager.router.getRoute(a.routeNodeId);
469
+ const routeB = this.peerManager.router.getRoute(b.routeNodeId);
470
+ const aDirect = routeA?.connection ? 0 : 1;
471
+ const bDirect = routeB?.connection ? 0 : 1;
472
+ if (aDirect !== bDirect) return aDirect - bDirect;
473
+ return (routeA?.latencyMs ?? 0) - (routeB?.latencyMs ?? 0);
474
+ });
475
+
400
476
  debug("proxy", `model raw="${rawModelId}" modelId="${modelId}" candidates=${candidates.map((c) => c.routeNodeId).join(",") || "none"}`);
401
477
  if (candidates.length === 0) {
402
478
  return { error: { status: 502, message: `No reachable node for model "${rawModelId}"` } };
@@ -519,10 +595,21 @@ export class ModelProxy {
519
595
  const encoder = new TextEncoder();
520
596
  const model = frame.payload.model;
521
597
 
598
+ let streamController: ReadableStreamDefaultController;
522
599
  const readable = new ReadableStream({
523
600
  start: (controller) => {
601
+ streamController = controller;
524
602
  this.startStreamAttempt(requestId, targetNodeId, frame, responseFormat, controller, encoder, model, failoverCandidates, buildFrame);
525
603
  },
604
+ cancel: () => {
605
+ // Client disconnected — find and clean up the pending request using this controller
606
+ for (const [id, p] of this.pending) {
607
+ if (p.controller === streamController) {
608
+ this.cleanupRequest(id, p.stableStreamId);
609
+ break;
610
+ }
611
+ }
612
+ },
526
613
  });
527
614
 
528
615
  // Emit setup events for responses API (once, before any attempts)
@@ -556,7 +643,7 @@ export class ModelProxy {
556
643
  this.cleanupRequest(requestId);
557
644
  this.peerManager.router.markFailed(requestId);
558
645
  this.tryStreamFailover(stableId, responseFormat, controller, encoder, model, failoverCandidates, buildFrame, `model request to "${targetNodeId}" timed out`);
559
- }, MODEL_TIMEOUT);
646
+ }, this.modelTimeout);
560
647
 
561
648
  this.pending.set(requestId, {
562
649
  resolve: () => {}, reject: () => {},
@@ -730,7 +817,7 @@ export class ModelProxy {
730
817
  this.pending.delete(requestId);
731
818
  this.peerManager.router.markFailed(requestId);
732
819
  reject(new Error(`Model request to "${targetNodeId}" timed out`));
733
- }, MODEL_TIMEOUT);
820
+ }, this.modelTimeout);
734
821
 
735
822
  this.pending.set(requestId, {
736
823
  resolve: resolve as (v: unknown) => void,
@@ -814,7 +901,7 @@ export class ModelProxy {
814
901
  .map((p) => p.nodeId),
815
902
  );
816
903
 
817
- const models = this.config.proxyModels.map((m) => {
904
+ const models = this.allProxyModels.map((m) => {
818
905
  const entry: Record<string, unknown> = {
819
906
  id: m.id,
820
907
  object: "model",
@@ -901,6 +988,7 @@ export class ModelProxy {
901
988
  }
902
989
  } catch {
903
990
  this.cleanupRequest(frame.id, pending.stableStreamId);
991
+ try { pending.controller?.close(); } catch { /* already closed */ }
904
992
  }
905
993
  }
906
994
 
@@ -920,7 +1008,10 @@ export class ModelProxy {
920
1008
  this.cleanupRequest(frame.id, stableId);
921
1009
  } else {
922
1010
  // Use full deltaObj when available (carries tool_calls etc.), otherwise simple text delta
923
- const delta = frame.payload.deltaObj ?? { content: frame.payload.delta };
1011
+ const delta = frame.payload.deltaObj ?? {
1012
+ content: frame.payload.delta,
1013
+ ...(frame.payload.reasoningDelta && { reasoning_content: frame.payload.reasoningDelta }),
1014
+ };
924
1015
  const chunkStableId = pending.stableStreamId ?? frame.id;
925
1016
  const chunk = { id: `chatcmpl-${chunkStableId}`, object: "chat.completion.chunk", choices: [{ index: 0, delta, finish_reason: null }] };
926
1017
  pending.controller!.enqueue(pending.encoder!.encode(`data: ${JSON.stringify(chunk)}\n\n`));
@@ -962,14 +1053,19 @@ export class ModelProxy {
962
1053
  }
963
1054
  }
964
1055
 
965
- private sendStreamDelta(to: string, id: string, delta: string, deltaObj?: unknown) {
1056
+ private sendStreamDelta(to: string, id: string, delta: string, deltaObj?: unknown, reasoningDelta?: string) {
966
1057
  this.peerManager.sendTo(to, {
967
1058
  type: "model_stream",
968
1059
  id,
969
1060
  from: this.config.nodeId,
970
1061
  to,
971
1062
  timestamp: Date.now(),
972
- payload: { delta, ...(deltaObj !== undefined && { deltaObj }), done: false },
1063
+ payload: {
1064
+ delta,
1065
+ ...(reasoningDelta && { reasoningDelta }),
1066
+ ...(deltaObj !== undefined && { deltaObj }),
1067
+ done: false,
1068
+ },
973
1069
  } satisfies ModelStreamChunk);
974
1070
  }
975
1071
 
@@ -1007,6 +1103,13 @@ export class ModelProxy {
1007
1103
 
1008
1104
  try {
1009
1105
  const endpoint = this.resolveModelEndpoint(model);
1106
+ if (!endpoint) {
1107
+ this.peerManager.sendTo(from, {
1108
+ type: "model_res", id, from: this.config.nodeId, to: from, timestamp: Date.now(),
1109
+ payload: { success: false, error: `No direct API endpoint configured for model "${payload.model}" (provider: ${model.provider}). Configure baseUrl/apiKey in ClawMatrix model config or OpenClaw provider config.` },
1110
+ } satisfies ModelResponse);
1111
+ return;
1112
+ }
1010
1113
  // Use payload.api override from requesting side, or cached API from previous auto-detection
1011
1114
  const cached = this.modelApiCache.get(model.id);
1012
1115
  const cachedApi = (cached && Date.now() - cached.ts < ModelProxy.MODEL_API_CACHE_TTL) ? cached.api : undefined;
@@ -1017,16 +1120,10 @@ export class ModelProxy {
1017
1120
  const url = `${endpoint.baseUrl}${path}`;
1018
1121
  const headers: Record<string, string> = { "Content-Type": "application/json" };
1019
1122
 
1020
- if (endpoint.direct) {
1021
- if (endpoint.apiKey) headers["Authorization"] = `Bearer ${endpoint.apiKey}`;
1022
- debug("model_req", `direct API call to ${url} (api=${endpoint.api})`);
1023
- } else {
1024
- const { authHeader } = this.gatewayInfo;
1025
- if (authHeader) headers["Authorization"] = authHeader;
1026
- debug("model_req", `gateway fallback to ${url}`);
1027
- }
1123
+ if (endpoint.apiKey) headers["Authorization"] = `Bearer ${endpoint.apiKey}`;
1124
+ debug("model_req", `direct API call to ${url} (api=${endpoint.api})`);
1028
1125
 
1029
- const modelField = endpoint.direct ? model.id : `${model.provider}/${model.id}`;
1126
+ const modelField = model.id;
1030
1127
  const srcFormat = payload.inputFormat ?? "chat";
1031
1128
 
1032
1129
  // Convert messages between formats if source and target API differ
@@ -1142,11 +1239,12 @@ export class ModelProxy {
1142
1239
  // Chat completions format
1143
1240
  lastUsage = parseUsage(parsed.usage) ?? lastUsage;
1144
1241
  const d = parsed.choices?.[0]?.delta;
1145
- const delta = d?.content || d?.reasoning_content || "";
1242
+ const delta = d?.content || "";
1243
+ const reasoningDelta = d?.reasoning_content || "";
1146
1244
  // Pass full delta object when it contains tool_calls or other structured data
1147
1245
  const hasStructured = d?.tool_calls || d?.refusal != null;
1148
- if (delta || hasStructured) {
1149
- this.sendStreamDelta(from, id, delta, hasStructured ? d : undefined);
1246
+ if (delta || reasoningDelta || hasStructured) {
1247
+ this.sendStreamDelta(from, id, delta, hasStructured ? d : undefined, reasoningDelta || undefined);
1150
1248
  contentSent = true;
1151
1249
  }
1152
1250
  }
@@ -1206,11 +1304,13 @@ export class ModelProxy {
1206
1304
  result = {}; // unused — chatFallbackResult takes precedence
1207
1305
  }
1208
1306
  let content: string;
1307
+ let reasoning = "";
1209
1308
  let message: unknown | undefined;
1210
1309
  let usage: { inputTokens: number; outputTokens: number } | undefined;
1211
1310
 
1212
1311
  if (chatFallbackResult) {
1213
1312
  ({ content, message, usage } = chatFallbackResult);
1313
+ reasoning = chatFallbackResult.reasoning ?? "";
1214
1314
  } else if (isResponsesApi) {
1215
1315
  // Responses API: extract text from output[].content[].text
1216
1316
  content = "";
@@ -1249,12 +1349,15 @@ export class ModelProxy {
1249
1349
  } else {
1250
1350
  // Chat completions format
1251
1351
  const msg = result.choices?.[0]?.message;
1252
- content = msg?.content || msg?.reasoning_content || "";
1352
+ content = msg?.content || "";
1353
+ reasoning = msg?.reasoning_content || "";
1253
1354
  // Carry full message object when it has tool_calls or other structured data
1254
1355
  if (msg?.tool_calls || msg?.refusal != null || msg?.function_call) {
1255
1356
  message = msg;
1256
1357
  }
1257
1358
  usage = parseUsage(result.usage);
1359
+ // If no content but has reasoning, use reasoning as content fallback
1360
+ if (!content && reasoning) content = reasoning;
1258
1361
  }
1259
1362
 
1260
1363
  this.peerManager.sendTo(from, {
@@ -1266,6 +1369,7 @@ export class ModelProxy {
1266
1369
  payload: {
1267
1370
  success: true,
1268
1371
  content,
1372
+ ...(reasoning && { reasoning }),
1269
1373
  ...(message !== undefined && { message }),
1270
1374
  usage,
1271
1375
  },
@@ -1292,7 +1396,7 @@ export class ModelProxy {
1292
1396
  modelField: string,
1293
1397
  payload: ModelRequest["payload"],
1294
1398
  headers: Record<string, string>,
1295
- ): Promise<{ content: string; message?: unknown; usage?: { inputTokens: number; outputTokens: number } } | null> {
1399
+ ): Promise<{ content: string; reasoning?: string; message?: unknown; usage?: { inputTokens: number; outputTokens: number } } | null> {
1296
1400
  try {
1297
1401
  const srcFormat = payload.inputFormat ?? "chat";
1298
1402
  const messages = srcFormat === "chat"
@@ -1316,10 +1420,11 @@ export class ModelProxy {
1316
1420
  if (!chatResp.ok) return null;
1317
1421
  const chatResult = await chatResp.json();
1318
1422
  const msg = chatResult.choices?.[0]?.message;
1319
- const content = msg?.content || msg?.reasoning_content || "";
1423
+ const content = msg?.content || "";
1424
+ const reasoningContent = msg?.reasoning_content || "";
1320
1425
  const message = (msg?.tool_calls || msg?.refusal != null || msg?.function_call) ? msg : undefined;
1321
1426
  const usage = parseUsage(chatResult.usage);
1322
- return { content, message, usage };
1427
+ return { content: content || reasoningContent, reasoning: reasoningContent || undefined, message, usage };
1323
1428
  } catch (err) {
1324
1429
  debug("model_req", `retryWithChatCompletions failed for "${modelField}": ${err instanceof Error ? err.message : String(err)}`);
1325
1430
  return null;