clawmatrix 0.1.6 → 0.1.11

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.
@@ -7,6 +7,7 @@ import type {
7
7
  ModelResponse,
8
8
  ModelStreamChunk,
9
9
  } from "./types.ts";
10
+ import { debug } from "./debug.ts";
10
11
 
11
12
  const MODEL_TIMEOUT = 120_000; // 2 minutes
12
13
 
@@ -35,21 +36,37 @@ export class ModelProxy {
35
36
  /** Start the local HTTP proxy server for OpenAI-compatible requests. */
36
37
  start() {
37
38
  this.httpServer = createServer(async (req, res) => {
38
- const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
39
-
40
- if (url.pathname === "/v1/chat/completions" && req.method === "POST") {
41
- const body = await this.readBody(req);
42
- const response = await this.handleChatCompletion(body);
43
- this.sendResponse(res, response);
44
- } else if (url.pathname === "/v1/models" && req.method === "GET") {
45
- const response = this.handleListModels();
46
- this.sendResponse(res, response);
47
- } else {
48
- res.writeHead(404, { "Content-Type": "text/plain" });
49
- res.end("Not Found");
39
+ try {
40
+ const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
41
+
42
+ const p = url.pathname.replace(/^\/v1/, "");
43
+ debug("proxy", `${req.method} ${url.pathname} ${p}`);
44
+
45
+ if (p === "/chat/completions" && req.method === "POST") {
46
+ const body = await this.readBody(req);
47
+ const response = await this.handleChatCompletion(body);
48
+ debug("proxy", `response status=${response.status}`);
49
+ this.sendResponse(res, response);
50
+ } else if (p === "/models" && req.method === "GET") {
51
+ const response = this.handleListModels();
52
+ this.sendResponse(res, response);
53
+ } else {
54
+ res.writeHead(404, { "Content-Type": "application/json" });
55
+ res.end(JSON.stringify({ error: { message: `No handler for ${req.method} ${url.pathname}` } }));
56
+ }
57
+ } catch {
58
+ if (!res.headersSent) {
59
+ res.writeHead(500, { "Content-Type": "application/json" });
60
+ }
61
+ res.end(JSON.stringify({ error: { message: "Internal proxy error" } }));
50
62
  }
51
63
  });
52
64
 
65
+ this.httpServer.on("error", (err) => {
66
+ // Log but don't crash — port conflict or other server error
67
+ console.error(`[clawmatrix] Model proxy server error: ${err.message}`);
68
+ });
69
+
53
70
  this.httpServer.listen(this.config.proxyPort, "127.0.0.1");
54
71
  }
55
72
 
@@ -84,12 +101,16 @@ export class ModelProxy {
84
101
  const pump = (): void => {
85
102
  reader.read().then(({ done, value }) => {
86
103
  if (done) {
104
+ reader.releaseLock();
87
105
  res.end();
88
106
  return;
89
107
  }
90
108
  res.write(value);
91
109
  pump();
92
- }).catch(() => res.end());
110
+ }).catch(() => {
111
+ reader.releaseLock();
112
+ res.end();
113
+ });
93
114
  };
94
115
  pump();
95
116
  }
@@ -115,20 +136,46 @@ export class ModelProxy {
115
136
  };
116
137
  }
117
138
 
118
- const modelId = body.model;
119
- // Check proxyModels for a nodeId routing hint
120
- const proxyModel = this.config.proxyModels.find((m) => m.id === modelId);
121
- const route = proxyModel?.nodeId
122
- ? this.peerManager.router.getRoute(proxyModel.nodeId)
139
+ const rawModelId = body.model;
140
+ // Parse "nodeId/model" format: first segment is nodeId, rest is model ID.
141
+ // OpenClaw sends "providerId/modelId" where providerId = nodeId, so this
142
+ // naturally handles both OpenClaw calls and direct curl calls.
143
+ // If no "/" present, treat entire string as model ID and auto-resolve.
144
+ let nodeId: string | undefined;
145
+ let modelId: string;
146
+ const slashIdx = rawModelId.indexOf("/");
147
+ if (slashIdx > 0) {
148
+ nodeId = rawModelId.slice(0, slashIdx);
149
+ modelId = rawModelId.slice(slashIdx + 1);
150
+ } else {
151
+ modelId = rawModelId;
152
+ }
153
+ debug("proxy", `model raw="${rawModelId}" nodeId=${nodeId ?? "auto"} modelId="${modelId}" stream=${body.stream ?? false}`);
154
+ const proxyModel = this.config.proxyModels.find((m) => m.id === modelId && (!nodeId || m.nodeId === nodeId))
155
+ ?? this.config.proxyModels.find((m) => m.id === modelId);
156
+ const route = nodeId
157
+ ? this.peerManager.router.getRoute(nodeId)
123
158
  : this.peerManager.router.resolveModel(modelId);
159
+ debug("proxy", `proxyModel=${proxyModel?.id ?? "none"} route=${route?.nodeId ?? "none"} reachable=${route ? this.peerManager.canReach(route.nodeId) : false}`);
124
160
  if (!route) {
125
161
  return {
126
162
  status: 404,
127
163
  headers: { "Content-Type": "application/json" },
128
- body: JSON.stringify({ error: { message: `Model "${modelId}" not found in cluster` } }),
164
+ body: JSON.stringify({ error: { message: `Model "${modelId}" not found in cluster (proxyModels: [${this.config.proxyModels.map(m => m.id).join(", ")}])` } }),
129
165
  };
130
166
  }
131
167
 
168
+ // Inject model identity so the LLM knows what it is
169
+ const messages = body.messages;
170
+ if (proxyModel?.description) {
171
+ const first = messages[0] as { role?: string; content?: string } | undefined;
172
+ if (first?.role === "system" && typeof first.content === "string") {
173
+ first.content = `[Model: ${proxyModel.description}]\n${first.content}`;
174
+ } else {
175
+ messages.unshift({ role: "system", content: `[Model: ${proxyModel.description}]` });
176
+ }
177
+ }
178
+
132
179
  const stream = body.stream ?? false;
133
180
  const requestId = crypto.randomUUID();
134
181
 
@@ -140,13 +187,22 @@ export class ModelProxy {
140
187
  timestamp: Date.now(),
141
188
  payload: {
142
189
  model: modelId,
143
- messages: body.messages,
190
+ messages,
144
191
  temperature: body.temperature,
145
192
  maxTokens: body.max_tokens,
146
193
  stream,
147
194
  },
148
195
  };
149
196
 
197
+ // Pre-check reachability before starting a stream (avoids silent empty response)
198
+ if (!this.peerManager.canReach(route.nodeId)) {
199
+ return {
200
+ status: 502,
201
+ headers: { "Content-Type": "application/json" },
202
+ body: JSON.stringify({ error: { message: `Cannot reach model node "${route.nodeId}"` } }),
203
+ };
204
+ }
205
+
150
206
  if (stream) {
151
207
  return this.handleStreamRequest(requestId, route.nodeId, frame);
152
208
  } else {
@@ -167,8 +223,13 @@ export class ModelProxy {
167
223
  this.pending.delete(requestId);
168
224
  this.peerManager.router.markFailed(requestId);
169
225
  try {
226
+ const errorChunk = {
227
+ id: `chatcmpl-${requestId}`,
228
+ object: "chat.completion.chunk",
229
+ choices: [{ index: 0, delta: { content: "\n\n[ClawMatrix] Error: model request timed out" }, finish_reason: "stop" }],
230
+ };
170
231
  controller.enqueue(
171
- encoder.encode(`data: ${JSON.stringify({ error: "timeout" })}\n\n`),
232
+ encoder.encode(`data: ${JSON.stringify(errorChunk)}\n\n`),
172
233
  );
173
234
  controller.enqueue(encoder.encode("data: [DONE]\n\n"));
174
235
  controller.close();
@@ -190,10 +251,13 @@ export class ModelProxy {
190
251
  if (!sent) {
191
252
  this.pending.delete(requestId);
192
253
  clearTimeout(timer);
254
+ const errChunk = {
255
+ id: `chatcmpl-${requestId}`,
256
+ object: "chat.completion.chunk",
257
+ choices: [{ index: 0, delta: { content: `[ClawMatrix] Cannot reach model node "${targetNodeId}"` }, finish_reason: "stop" }],
258
+ };
193
259
  controller.enqueue(
194
- encoder.encode(
195
- `data: ${JSON.stringify({ error: "Cannot reach model node" })}\n\n`,
196
- ),
260
+ encoder.encode(`data: ${JSON.stringify(errChunk)}\n\n`),
197
261
  );
198
262
  controller.enqueue(encoder.encode("data: [DONE]\n\n"));
199
263
  controller.close();
@@ -306,7 +370,31 @@ export class ModelProxy {
306
370
  handleModelResponse(frame: ModelResponse) {
307
371
  if (this.peerManager.router.isFailed(frame.id)) return;
308
372
  const pending = this.pending.get(frame.id);
309
- if (!pending || pending.stream) return;
373
+ if (!pending) return;
374
+
375
+ // For stream requests, handle error responses (the remote node couldn't
376
+ // process the request and sent model_res instead of model_stream).
377
+ if (pending.stream) {
378
+ if (!frame.payload.success && pending.controller && pending.encoder) {
379
+ clearTimeout(pending.timer);
380
+ this.pending.delete(frame.id);
381
+ try {
382
+ const errChunk = {
383
+ id: `chatcmpl-${frame.id}`,
384
+ object: "chat.completion.chunk",
385
+ choices: [{ index: 0, delta: { content: `[ClawMatrix] Remote error: ${frame.payload.error}` }, finish_reason: "stop" }],
386
+ };
387
+ pending.controller.enqueue(
388
+ pending.encoder.encode(`data: ${JSON.stringify(errChunk)}\n\n`),
389
+ );
390
+ pending.controller.enqueue(pending.encoder.encode("data: [DONE]\n\n"));
391
+ pending.controller.close();
392
+ } catch {
393
+ // controller may already be closed
394
+ }
395
+ }
396
+ return;
397
+ }
310
398
 
311
399
  clearTimeout(pending.timer);
312
400
  this.pending.delete(frame.id);
@@ -314,27 +402,28 @@ export class ModelProxy {
314
402
  }
315
403
 
316
404
  handleModelStream(frame: ModelStreamChunk) {
405
+ debug("stream", `id=${frame.id} done=${frame.payload.done} delta=${JSON.stringify(frame.payload.delta?.slice?.(0, 50) ?? frame.payload.delta)} failed=${this.peerManager.router.isFailed(frame.id)} hasPending=${this.pending.has(frame.id)}`);
317
406
  if (this.peerManager.router.isFailed(frame.id)) return;
318
407
  const pending = this.pending.get(frame.id);
319
408
  if (!pending?.stream || !pending.controller || !pending.encoder) return;
320
409
 
321
410
  try {
322
411
  if (frame.payload.done) {
412
+ const finalChunk: Record<string, unknown> = {
413
+ id: `chatcmpl-${frame.id}`,
414
+ object: "chat.completion.chunk",
415
+ choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
416
+ };
323
417
  if (frame.payload.usage) {
324
- const usageChunk = {
325
- id: `chatcmpl-${frame.id}`,
326
- object: "chat.completion.chunk",
327
- choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
328
- usage: {
329
- prompt_tokens: frame.payload.usage.inputTokens,
330
- completion_tokens: frame.payload.usage.outputTokens,
331
- total_tokens: frame.payload.usage.inputTokens + frame.payload.usage.outputTokens,
332
- },
418
+ finalChunk.usage = {
419
+ prompt_tokens: frame.payload.usage.inputTokens,
420
+ completion_tokens: frame.payload.usage.outputTokens,
421
+ total_tokens: frame.payload.usage.inputTokens + frame.payload.usage.outputTokens,
333
422
  };
334
- pending.controller.enqueue(
335
- pending.encoder.encode(`data: ${JSON.stringify(usageChunk)}\n\n`),
336
- );
337
423
  }
424
+ pending.controller.enqueue(
425
+ pending.encoder.encode(`data: ${JSON.stringify(finalChunk)}\n\n`),
426
+ );
338
427
  pending.controller.enqueue(pending.encoder.encode("data: [DONE]\n\n"));
339
428
  pending.controller.close();
340
429
  clearTimeout(pending.timer);
@@ -364,6 +453,7 @@ export class ModelProxy {
364
453
  /** Handle model_req locally: forward to OpenClaw's configured model provider. */
365
454
  async handleModelRequest(frame: ModelRequest): Promise<void> {
366
455
  const { id, from, payload } = frame;
456
+ debug("model_req", `handling model="${payload.model}" from=${from} stream=${payload.stream}`);
367
457
 
368
458
  const model = this.config.models.find((m) => m.id === payload.model);
369
459
  if (!model) {
@@ -392,63 +482,96 @@ export class ModelProxy {
392
482
  temperature: payload.temperature,
393
483
  max_tokens: payload.maxTokens,
394
484
  stream: payload.stream,
485
+ ...(payload.stream ? { stream_options: { include_usage: true } } : {}),
395
486
  }),
396
487
  });
397
488
 
489
+ if (!response.ok) {
490
+ const errBody = await response.text();
491
+ throw new Error(`Upstream ${response.status}: ${errBody.slice(0, 200)}`);
492
+ }
493
+
398
494
  if (payload.stream) {
399
495
  const reader = response.body?.getReader();
400
496
  if (!reader) throw new Error("No response body");
401
497
 
402
- const decoder = new TextDecoder();
403
- let buffer = "";
404
-
405
- while (true) {
406
- const { done, value } = await reader.read();
407
- if (done) break;
408
-
409
- buffer += decoder.decode(value, { stream: true });
410
- const lines = buffer.split("\n");
411
- buffer = lines.pop()!;
412
-
413
- for (const line of lines) {
414
- if (!line.startsWith("data: ")) continue;
415
- const data = line.slice(6).trim();
416
- if (data === "[DONE]") {
417
- this.peerManager.sendTo(from, {
418
- type: "model_stream",
419
- id,
420
- from: this.config.nodeId,
421
- to: from,
422
- timestamp: Date.now(),
423
- payload: { delta: "", done: true },
424
- } satisfies ModelStreamChunk);
425
- break;
426
- }
498
+ try {
499
+ const decoder = new TextDecoder();
500
+ let buffer = "";
501
+ let lastUsage: { inputTokens: number; outputTokens: number } | undefined;
502
+ let streamDone = false;
503
+
504
+ while (!streamDone) {
505
+ const { done, value } = await reader.read();
506
+ if (done) break;
507
+
508
+ buffer += decoder.decode(value, { stream: true });
509
+ const lines = buffer.split("\n");
510
+ buffer = lines.pop()!;
427
511
 
428
- try {
429
- const parsed = JSON.parse(data);
430
- const delta = parsed.choices?.[0]?.delta?.content ?? "";
431
- if (delta) {
512
+ for (const line of lines) {
513
+ if (!line.startsWith("data: ")) continue;
514
+ const data = line.slice(6).trim();
515
+ if (data === "[DONE]") {
432
516
  this.peerManager.sendTo(from, {
433
517
  type: "model_stream",
434
518
  id,
435
519
  from: this.config.nodeId,
436
520
  to: from,
437
521
  timestamp: Date.now(),
438
- payload: { delta, done: false },
522
+ payload: { delta: "", done: true, usage: lastUsage },
439
523
  } satisfies ModelStreamChunk);
524
+ streamDone = true;
525
+ break;
526
+ }
527
+
528
+ try {
529
+ const parsed = JSON.parse(data);
530
+ if (parsed.usage) {
531
+ lastUsage = {
532
+ inputTokens: parsed.usage.prompt_tokens,
533
+ outputTokens: parsed.usage.completion_tokens,
534
+ };
535
+ }
536
+ const d = parsed.choices?.[0]?.delta;
537
+ const delta = d?.content || d?.reasoning_content || "";
538
+ if (delta) {
539
+ this.peerManager.sendTo(from, {
540
+ type: "model_stream",
541
+ id,
542
+ from: this.config.nodeId,
543
+ to: from,
544
+ timestamp: Date.now(),
545
+ payload: { delta, done: false },
546
+ } satisfies ModelStreamChunk);
547
+ }
548
+ } catch {
549
+ // skip malformed chunks
440
550
  }
441
- } catch {
442
- // skip malformed chunks
443
551
  }
444
552
  }
553
+ // If the upstream closed without sending [DONE], send a completion
554
+ // frame so the requesting side doesn't hang until MODEL_TIMEOUT.
555
+ if (!streamDone) {
556
+ this.peerManager.sendTo(from, {
557
+ type: "model_stream",
558
+ id,
559
+ from: this.config.nodeId,
560
+ to: from,
561
+ timestamp: Date.now(),
562
+ payload: { delta: "", done: true, usage: lastUsage },
563
+ } satisfies ModelStreamChunk);
564
+ }
565
+ } finally {
566
+ reader.releaseLock();
445
567
  }
446
568
  } else {
447
569
  const result = (await response.json()) as {
448
- choices?: { message?: { content?: string } }[];
570
+ choices?: { message?: { content?: string; reasoning_content?: string } }[];
449
571
  usage?: { prompt_tokens: number; completion_tokens: number };
450
572
  };
451
- const content = result.choices?.[0]?.message?.content ?? "";
573
+ const msg = result.choices?.[0]?.message;
574
+ const content = msg?.content || msg?.reasoning_content || "";
452
575
  const usage = result.usage;
453
576
 
454
577
  this.peerManager.sendTo(from, {
@@ -33,6 +33,7 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
33
33
  private stopped = false;
34
34
  /** Map from ws WebSocket to Connection for inbound connections. */
35
35
  private inboundConnections = new Map<WsWebSocket, Connection>();
36
+ private gossipDebounceTimer: ReturnType<typeof setTimeout> | null = null;
36
37
 
37
38
  constructor(config: ClawMatrixConfig) {
38
39
  super();
@@ -62,6 +63,10 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
62
63
 
63
64
  async stop() {
64
65
  this.stopped = true;
66
+ if (this.gossipDebounceTimer) {
67
+ clearTimeout(this.gossipDebounceTimer);
68
+ this.gossipDebounceTimer = null;
69
+ }
65
70
  for (const timer of this.reconnectTimers.values()) {
66
71
  clearTimeout(timer);
67
72
  }
@@ -86,6 +91,8 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
86
91
  this.httpServer.close();
87
92
  this.httpServer = null;
88
93
  }
94
+
95
+ this.router.destroy();
89
96
  }
90
97
 
91
98
  // ── Inbound WS server (node:http + ws) ──────────────────────────
@@ -119,6 +126,10 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
119
126
  });
120
127
  });
121
128
 
129
+ this.httpServer.on("error", (err) => {
130
+ console.error(`[clawmatrix] WS server error on port ${port}: ${err.message}`);
131
+ });
132
+
122
133
  this.httpServer.listen(port, hostname);
123
134
  }
124
135
 
@@ -319,13 +330,17 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
319
330
  }
320
331
  }
321
332
 
322
- // If we learned new info, re-sync with other peers
333
+ // If we learned new info, re-sync with other peers (debounced to avoid storms)
323
334
  if (changed) {
324
- for (const conn of this.router.getDirectConnections()) {
325
- if (conn !== from && conn.isOpen) {
326
- this.sendPeerSync(conn);
335
+ if (this.gossipDebounceTimer) clearTimeout(this.gossipDebounceTimer);
336
+ this.gossipDebounceTimer = setTimeout(() => {
337
+ this.gossipDebounceTimer = null;
338
+ for (const conn of this.router.getDirectConnections()) {
339
+ if (conn !== from && conn.isOpen) {
340
+ this.sendPeerSync(conn);
341
+ }
327
342
  }
328
- }
343
+ }, 100);
329
344
  }
330
345
  }
331
346
 
@@ -334,6 +349,18 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
334
349
  return this.router.sendTo(nodeId, frame);
335
350
  }
336
351
 
352
+ /** Check if a node is reachable (direct or via relay) without sending. */
353
+ canReach(nodeId: string): boolean {
354
+ const route = this.router.getRoute(nodeId);
355
+ if (!route) return false;
356
+ if (route.connection?.isOpen) return true;
357
+ if (route.reachableVia) {
358
+ const relay = this.router.getRoute(route.reachableVia);
359
+ return !!relay?.connection?.isOpen;
360
+ }
361
+ return false;
362
+ }
363
+
337
364
  broadcast(frame: ClusterFrame | AnyClusterFrame) {
338
365
  this.router.broadcast(frame);
339
366
  }
package/src/router.ts CHANGED
@@ -4,6 +4,7 @@ import type { Connection } from "./connection.ts";
4
4
  const DEFAULT_TTL = 3;
5
5
  const SEEN_FRAME_TTL = 60_000; // 60s dedup window
6
6
  const MAX_SEEN_FRAMES = 10_000;
7
+ const PRUNE_INTERVAL = 30_000; // periodic cleanup every 30s
7
8
 
8
9
  export interface RouteEntry {
9
10
  nodeId: string;
@@ -24,6 +25,7 @@ export class Router {
24
25
  private routes = new Map<string, RouteEntry>();
25
26
  private connections = new Map<string, Connection>(); // nodeId → direct connection
26
27
  private seenFrames = new Map<string, number>(); // frameId → timestamp
28
+ private pruneTimer: ReturnType<typeof setInterval> | null = null;
27
29
 
28
30
  constructor(
29
31
  nodeId: string,
@@ -33,6 +35,17 @@ export class Router {
33
35
  this.localAgents = localCapabilities?.agents ?? [];
34
36
  this.localModels = localCapabilities?.models ?? [];
35
37
  this.localTags = localCapabilities?.tags ?? [];
38
+
39
+ this.pruneTimer = setInterval(() => this.pruneSeenFrames(true), PRUNE_INTERVAL);
40
+ }
41
+
42
+ /** Stop periodic cleanup. Call on shutdown. */
43
+ destroy() {
44
+ if (this.pruneTimer) {
45
+ clearInterval(this.pruneTimer);
46
+ this.pruneTimer = null;
47
+ }
48
+ this.seenFrames.clear();
36
49
  }
37
50
 
38
51
  // ── Route table management ─────────────────────────────────────
@@ -120,6 +133,12 @@ export class Router {
120
133
  }
121
134
  }
122
135
 
136
+ // Fallback: if no agent ID or tag matched, try matching by nodeId
137
+ if (candidates.length === 0 && !isTagQuery) {
138
+ const byNode = this.routes.get(target);
139
+ if (byNode) candidates.push(byNode);
140
+ }
141
+
123
142
  if (candidates.length === 0) return undefined;
124
143
 
125
144
  // Sort: direct connections first, then by latency
@@ -205,7 +224,9 @@ export class Router {
205
224
  tryRelay(frame: ClusterFrame): boolean {
206
225
  if (!frame.to || frame.to === this.nodeId) return false;
207
226
 
208
- const ttl = (frame.ttl ?? DEFAULT_TTL) - 1;
227
+ const rawTtl = frame.ttl ?? DEFAULT_TTL;
228
+ if (typeof rawTtl !== "number" || !Number.isFinite(rawTtl) || rawTtl < 1) return false;
229
+ const ttl = rawTtl - 1;
209
230
  if (ttl <= 0) return false;
210
231
 
211
232
  const relayed = this.sendTo(frame.to, { ...frame, ttl });
@@ -232,8 +253,8 @@ export class Router {
232
253
  return this.seenFrames.has(`failed:${requestId}`);
233
254
  }
234
255
 
235
- private pruneSeenFrames() {
236
- if (this.seenFrames.size <= MAX_SEEN_FRAMES) return;
256
+ private pruneSeenFrames(force = false) {
257
+ if (!force && this.seenFrames.size <= MAX_SEEN_FRAMES) return;
237
258
  const now = Date.now();
238
259
  for (const [id, ts] of this.seenFrames) {
239
260
  if (now - ts > SEEN_FRAME_TTL) {
package/src/tool-proxy.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { PeerManager } from "./peer-manager.ts";
2
2
  import type { ClawMatrixConfig, ToolProxyConfig } from "./config.ts";
3
3
  import type { ToolProxyRequest, ToolProxyResponse } from "./types.ts";
4
+ import { isLocalTool, executeLocally } from "./local-tools.ts";
4
5
 
5
6
  const TOOL_TIMEOUT = 30_000;
6
7
 
@@ -113,7 +114,9 @@ export class ToolProxy {
113
114
  }
114
115
 
115
116
  try {
116
- const result = await this.executeViaGateway(payload.tool, payload.params);
117
+ const result = isLocalTool(payload.tool)
118
+ ? await executeLocally(payload.tool, payload.params)
119
+ : await this.executeViaGateway(payload.tool, payload.params);
117
120
  this.sendResponse(id, from, { success: true, result });
118
121
  } catch (err) {
119
122
  this.sendResponse(id, from, {
package/src/types.ts CHANGED
@@ -113,6 +113,15 @@ export interface HandoffRequest extends ClusterFrame {
113
113
  };
114
114
  }
115
115
 
116
+ export interface HandoffStreamChunk extends ClusterFrame {
117
+ type: "handoff_stream";
118
+ id: string;
119
+ payload: {
120
+ delta: string;
121
+ done: boolean;
122
+ };
123
+ }
124
+
116
125
  export interface HandoffResponse extends ClusterFrame {
117
126
  type: "handoff_res";
118
127
  id: string;
@@ -161,10 +170,26 @@ export interface AgentInfo {
161
170
  tags: string[];
162
171
  }
163
172
 
173
+ export interface ModelCompatInfo {
174
+ supportsTools?: boolean;
175
+ supportsStore?: boolean;
176
+ supportsDeveloperRole?: boolean;
177
+ supportsReasoningEffort?: boolean;
178
+ supportsUsageInStreaming?: boolean;
179
+ supportsStrictMode?: boolean;
180
+ maxTokensField?: "max_completion_tokens" | "max_tokens";
181
+ thinkingFormat?: "openai" | "zai" | "qwen";
182
+ requiresToolResultName?: boolean;
183
+ requiresAssistantAfterToolResult?: boolean;
184
+ requiresThinkingAsText?: boolean;
185
+ requiresMistralToolIds?: boolean;
186
+ }
187
+
164
188
  export interface ModelInfo {
165
189
  id: string;
166
190
  provider: string;
167
191
  description?: string;
192
+ compat?: ModelCompatInfo;
168
193
  }
169
194
 
170
195
  export interface PeerInfo {
@@ -197,6 +222,7 @@ export type AnyClusterFrame =
197
222
  | ModelResponse
198
223
  | ModelStreamChunk
199
224
  | HandoffRequest
225
+ | HandoffStreamChunk
200
226
  | HandoffResponse
201
227
  | SendMessage
202
228
  | ToolProxyRequest