clawmatrix 0.1.22 → 0.1.23

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawmatrix",
3
- "version": "0.1.22",
3
+ "version": "0.1.23",
4
4
  "description": "Decentralized mesh cluster plugin for OpenClaw — inter-gateway communication, model proxy, task handoff, and tool proxy.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/connection.ts CHANGED
@@ -278,6 +278,8 @@ export class Connection extends EventEmitter<ConnectionEvents> {
278
278
  const interval = HEARTBEAT_BASE + Math.random() * HEARTBEAT_JITTER;
279
279
  this.heartbeatTimer = setTimeout(() => {
280
280
  if (this.closed) return;
281
+ // Increment before checking: this ping is about to be sent and
282
+ // counts as outstanding until a pong arrives.
281
283
  this.missedPongs++;
282
284
  if (this.missedPongs >= HEARTBEAT_TIMEOUT_COUNT) {
283
285
  this.close(4002, "heartbeat timeout");
package/src/handoff.ts CHANGED
@@ -203,7 +203,7 @@ export class HandoffManager {
203
203
  clearTimeout(pending.timer);
204
204
  pending.timer = this.createTimeout(
205
205
  frame.id,
206
- frame.from,
206
+ pending.targetNodeId,
207
207
  pending.target,
208
208
  pending.task,
209
209
  pending.context,
package/src/index.ts CHANGED
@@ -1,5 +1,5 @@
1
- import type { OpenClawPluginApi, GatewayRequestHandlerOptions } from "openclaw/plugin-sdk";
2
- import { ClawMatrixConfigSchema, parseConfig } from "./config.ts";
1
+ import type { OpenClawPluginApi, OpenClawConfig, GatewayRequestHandlerOptions } from "openclaw/plugin-sdk";
2
+ import { ClawMatrixConfigSchema, parseConfig, type ClawMatrixConfig } from "./config.ts";
3
3
  import { createClusterService, getClusterRuntime } from "./cluster-service.ts";
4
4
  import { createClusterHandoffTool } from "./tools/cluster-handoff.ts";
5
5
  import { createClusterHandoffReplyTool } from "./tools/cluster-handoff-reply.ts";
@@ -12,6 +12,59 @@ import { createClusterToolTool } from "./tools/cluster-tool.ts";
12
12
  import { createClusterEventsTool } from "./tools/cluster-events.ts";
13
13
  import { registerClusterCli } from "./cli.ts";
14
14
 
15
+ /**
16
+ * Auto-discover models from OpenClaw's models.providers config.
17
+ * Iterates all configured providers and their models, building the
18
+ * full model list with endpoint info (baseUrl, apiKey, api type, etc.).
19
+ * Excludes clawmatrix proxy providers (remote models from peer nodes).
20
+ */
21
+ type ProviderEntry = { baseUrl?: string; apiKey?: string; api?: string; models?: Array<Record<string, unknown>> };
22
+
23
+ function discoverModels(
24
+ openclawConfig: OpenClawConfig,
25
+ config: ClawMatrixConfig,
26
+ ): ClawMatrixConfig["models"] {
27
+ const cfg = openclawConfig as Record<string, unknown>;
28
+ const providers = (cfg.models as { providers?: Record<string, ProviderEntry> } | undefined)?.providers;
29
+ if (!providers || typeof providers !== "object") return [];
30
+
31
+ // Collect proxyModel node IDs to exclude clawmatrix-registered providers
32
+ const proxyNodeIds = new Set(config.proxyModels.map((m) => m.nodeId));
33
+
34
+ const result: ClawMatrixConfig["models"] = [];
35
+ for (const [providerId, providerConfig] of Object.entries(providers)) {
36
+ // Skip clawmatrix proxy providers (remote models from peer nodes)
37
+ if (proxyNodeIds.has(providerId)) continue;
38
+
39
+ const models = providerConfig?.models;
40
+ if (!Array.isArray(models)) continue;
41
+
42
+ for (const m of models) {
43
+ if (!m.id || typeof m.id !== "string") continue;
44
+ result.push({
45
+ id: m.id,
46
+ provider: providerId,
47
+ description: m.name as string | undefined,
48
+ baseUrl: providerConfig?.baseUrl,
49
+ apiKey: typeof providerConfig?.apiKey === "string" ? providerConfig.apiKey : undefined,
50
+ api: (m.api ?? providerConfig?.api) as ClawMatrixConfig["models"][0]["api"],
51
+ contextWindow: m.contextWindow as number | undefined,
52
+ maxTokens: m.maxTokens as number | undefined,
53
+ reasoning: m.reasoning as boolean | undefined,
54
+ input: m.input as ("text" | "image")[] | undefined,
55
+ cost: m.cost as { input: number; output: number; cacheRead: number; cacheWrite: number } | undefined,
56
+ compat: m.compat as ClawMatrixConfig["models"][0]["compat"],
57
+ });
58
+ }
59
+ }
60
+
61
+ if (result.length > 0) {
62
+ console.debug(`[clawmatrix] Auto-discovered ${result.length} model(s) from models.providers: ${result.map((m) => `${m.provider}/${m.id}`).join(", ")}`);
63
+ }
64
+
65
+ return result;
66
+ }
67
+
15
68
  const plugin = {
16
69
  id: "clawmatrix",
17
70
  name: "ClawMatrix",
@@ -36,6 +89,11 @@ const plugin = {
36
89
  return;
37
90
  }
38
91
 
92
+ // Auto-discover models from agents.defaults.models if no explicit models configured
93
+ if (config.models.length === 0) {
94
+ config = { ...config, models: discoverModels(api.config, config) };
95
+ }
96
+
39
97
  // Background service: manages mesh connections, WS listener, heartbeat
40
98
  api.registerService(createClusterService(config, api.config, api.runtime.version));
41
99
 
@@ -60,26 +118,42 @@ const plugin = {
60
118
  const models = ((cfg).models ??= {}) as Record<string, unknown>;
61
119
  const providers = (models.providers ??= {}) as Record<string, unknown>;
62
120
  for (const [nodeId, nodeModels] of Object.entries(modelsByNode)) {
63
- if (!providers[nodeId]) {
64
- const api = nodeApiType[nodeId] ?? "openai-completions";
65
- providers[nodeId] = { baseUrl, apiKey: "sk-clawmatrix-proxy", api, models: nodeModels };
121
+ const apiType = nodeApiType[nodeId] ?? "openai-completions";
122
+ const existing = providers[nodeId] as Record<string, unknown> | undefined;
123
+ if (existing) {
124
+ // Provider already exists (e.g. from models.json or config reload) —
125
+ // ensure the dummy apiKey is always present so auth resolution succeeds.
126
+ if (!existing.apiKey) existing.apiKey = "sk-clawmatrix-proxy";
127
+ if (!existing.baseUrl) existing.baseUrl = baseUrl;
128
+ } else {
129
+ providers[nodeId] = { baseUrl, apiKey: "sk-clawmatrix-proxy", api: apiType, models: nodeModels };
66
130
  }
67
131
  }
68
132
  };
69
133
 
70
- patchProviders(api.config as Record<string, unknown>);
71
-
72
- // Also patch the runtime config snapshot (loadConfig returns it by reference).
73
- // activateSecretsRuntimeSnapshot clones the config, so api.config and the
74
- // snapshot returned by loadConfig() are separate objects — patch both.
75
- try {
76
- const snapshot = api.runtime.config.loadConfig();
77
- if (snapshot && snapshot !== api.config) {
78
- patchProviders(snapshot as Record<string, unknown>);
134
+ // Patch all known config objects.
135
+ // activateSecretsRuntimeSnapshot clones config on startup & hot-reload,
136
+ // so our injected providers get lost. There is no plugin-facing "config_reload"
137
+ // event, so we re-patch periodically and skip when the reference hasn't changed.
138
+ let lastSnapshotRef: unknown = null;
139
+ const patchAllConfigs = () => {
140
+ patchProviders(api.config as Record<string, unknown>);
141
+ try {
142
+ const snapshot = api.runtime.config.loadConfig();
143
+ if (snapshot && snapshot !== lastSnapshotRef) {
144
+ lastSnapshotRef = snapshot;
145
+ patchProviders(snapshot as Record<string, unknown>);
146
+ }
147
+ } catch {
148
+ // Best-effort
79
149
  }
80
- } catch {
81
- // Best-effort; api.config patch is the fallback
82
- }
150
+ };
151
+
152
+ patchAllConfigs();
153
+
154
+ const repatchTimer = setInterval(patchAllConfigs, 10_000);
155
+ repatchTimer.unref?.();
156
+ api.on("dispose", () => clearInterval(repatchTimer));
83
157
 
84
158
  for (const [nodeId, models] of Object.entries(modelsByNode)) {
85
159
  api.registerProvider({
@@ -165,6 +239,11 @@ const plugin = {
165
239
  },
166
240
  );
167
241
 
242
+ // Log model selection on each LLM call (fire-and-forget)
243
+ api.on("llm_input", (event) => {
244
+ api.logger.debug(`[clawmatrix] llm_input: provider=${event.provider} model=${event.model}`);
245
+ });
246
+
168
247
  // CLI subcommand
169
248
  api.registerCli(registerClusterCli, { commands: ["clawmatrix"] });
170
249
 
@@ -48,8 +48,10 @@ export class KnowledgeSync {
48
48
  private syncStates = new Map<string, Automerge.SyncState>();
49
49
  private watcher: FSWatcher | null = null;
50
50
  private debounceTimer: ReturnType<typeof setTimeout> | null = null;
51
- /** Paths currently being written by exportToFs suppressed from fs watcher. */
52
- private writingPaths = new Set<string>();
51
+ /** Paths recently written by exportToFs with their expected content used to
52
+ * suppress watcher-triggered syncs for our own writes. Entries are cleared
53
+ * once handleLocalChanges confirms the file content matches. */
54
+ private writtenByExport = new Map<string, string>();
53
55
  private opts: KnowledgeSyncOptions;
54
56
  private ig: Ignore = ignore();
55
57
 
@@ -90,8 +92,6 @@ export class KnowledgeSync {
90
92
  // Start watching for file changes
91
93
  this.watcher = watch(this.opts.workspacePath, { recursive: true }, (_event, filename) => {
92
94
  if (!filename) return;
93
- // Ignore files currently being written by export
94
- if (this.writingPaths.has(filename)) return;
95
95
  // Ignore hidden files
96
96
  if (filename.startsWith(".")) return;
97
97
  // Ignore gitignored files
@@ -196,12 +196,22 @@ export class KnowledgeSync {
196
196
  const currentFiles = await this.readWorkspaceFiles();
197
197
  const docFiles = this.doc.files ?? {};
198
198
 
199
+ // Clear export markers whose content matches the current file (our write landed).
200
+ // If the content differs, a real local edit happened after export — treat it as modified.
201
+ for (const [relPath, expectedContent] of this.writtenByExport) {
202
+ if (currentFiles[relPath] === expectedContent) {
203
+ this.writtenByExport.delete(relPath);
204
+ }
205
+ }
206
+
199
207
  // Collect changed files for logging
200
208
  const added: string[] = [];
201
209
  const modified: string[] = [];
202
210
  const deleted: string[] = [];
203
211
 
204
212
  for (const [relPath, content] of Object.entries(currentFiles)) {
213
+ // Skip files that were just written by exportToFs and haven't been edited since
214
+ if (this.writtenByExport.has(relPath)) continue;
205
215
  if (!(relPath in docFiles)) {
206
216
  added.push(relPath);
207
217
  } else if (docFiles[relPath] !== content) {
@@ -320,28 +330,21 @@ export class KnowledgeSync {
320
330
  // Don't export files that would be gitignored
321
331
  if (this.isIgnored(relPath)) continue;
322
332
  if (currentFiles[relPath] !== content) {
323
- this.writingPaths.add(relPath);
333
+ this.writtenByExport.set(relPath, content);
324
334
  const absPath = path.join(this.opts.workspacePath, relPath);
325
335
  await mkdir(path.dirname(absPath), { recursive: true });
326
336
  await writeFile(absPath, content, "utf-8");
327
- setTimeout(() => this.writingPaths.delete(relPath), 500);
328
337
  written++;
329
338
  }
330
339
  }
331
340
 
332
- let removed = 0;
333
- for (const relPath of Object.keys(currentFiles)) {
334
- if (!(relPath in docFiles)) {
335
- this.writingPaths.add(relPath);
336
- const absPath = path.join(this.opts.workspacePath, relPath);
337
- await unlink(absPath).catch(() => {});
338
- setTimeout(() => this.writingPaths.delete(relPath), 500);
339
- removed++;
340
- }
341
- }
341
+ // Note: we intentionally do NOT delete local files that are absent from
342
+ // the doc. A locally created file that hasn't been synced yet would be
343
+ // lost if we deleted it here. Deletions propagate through the doc via
344
+ // handleLocalChanges() → Automerge change → broadcastSync() instead.
342
345
 
343
- if (written > 0 || removed > 0) {
344
- debug(TAG, `exported to filesystem: ${written} written, ${removed} removed`);
346
+ if (written > 0) {
347
+ debug(TAG, `exported to filesystem: ${written} written`);
345
348
  }
346
349
  }
347
350
 
@@ -126,13 +126,20 @@ async function executeExec(params: ExecParams): Promise<ToolResult> {
126
126
 
127
127
  // ── read/write/edit: reuse pi-coding-agent factories ───────────────
128
128
 
129
- const piToolCache = new Map<string, { execute: Function }>();
129
+ /** Cache key includes cwd so tools are recreated if the working directory changes. */
130
+ let piToolCache = new Map<string, { execute: Function }>();
131
+ let piToolCwd = "";
130
132
 
131
133
  function getPiTool(name: string): { execute: Function } {
134
+ const cwd = process.cwd();
135
+ if (cwd !== piToolCwd) {
136
+ piToolCache = new Map();
137
+ piToolCwd = cwd;
138
+ }
139
+
132
140
  let tool = piToolCache.get(name);
133
141
  if (tool) return tool;
134
142
 
135
- const cwd = process.cwd();
136
143
  switch (name) {
137
144
  case "read":
138
145
  tool = createReadTool(cwd);
@@ -12,6 +12,15 @@ import { debug } from "./debug.ts";
12
12
  import { readBody } from "./http-utils.ts";
13
13
 
14
14
  const MODEL_TIMEOUT = 120_000; // 2 minutes
15
+
16
+ /** Normalize usage from OpenAI-compatible APIs (supports both field naming conventions). */
17
+ function parseUsage(usage: Record<string, number> | undefined): { inputTokens: number; outputTokens: number } | undefined {
18
+ if (!usage) return undefined;
19
+ return {
20
+ inputTokens: usage.input_tokens ?? usage.prompt_tokens ?? 0,
21
+ outputTokens: usage.output_tokens ?? usage.completion_tokens ?? 0,
22
+ };
23
+ }
15
24
  const MAX_STREAM_BUFFER = 1_048_576; // 1MB — guard against upstream not sending newlines
16
25
 
17
26
  type ResponseFormat = "chat" | "responses";
@@ -54,6 +63,11 @@ export class ModelProxy {
54
63
  private gatewayInfo: GatewayInfo;
55
64
  private openclawConfig: OpenClawConfig;
56
65
 
66
+ /** Cache of models that need a different API format than configured (detected at runtime).
67
+ * Entries expire after 10 minutes so upstream upgrades are eventually detected. */
68
+ private modelApiCache = new Map<string, { api: string; ts: number }>();
69
+ private static readonly MODEL_API_CACHE_TTL = 600_000; // 10 minutes
70
+
57
71
  constructor(config: ClawMatrixConfig, peerManager: PeerManager, gatewayInfo: GatewayInfo, openclawConfig: OpenClawConfig) {
58
72
  this.config = config;
59
73
  this.peerManager = peerManager;
@@ -227,9 +241,11 @@ export class ModelProxy {
227
241
  }
228
242
 
229
243
  // 2. Read from OpenClaw's models.providers[provider]
230
- const providers = (this.openclawConfig as Record<string, unknown>).models as
231
- { providers?: Record<string, { baseUrl?: string; apiKey?: string; api?: string }> } | undefined;
232
- const providerConfig = providers?.providers?.[model.provider];
244
+ const modelsConfig = (this.openclawConfig as Record<string, unknown>).models;
245
+ const providers = (modelsConfig && typeof modelsConfig === "object")
246
+ ? (modelsConfig as { providers?: Record<string, { baseUrl?: string; apiKey?: string; api?: string }> }).providers
247
+ : undefined;
248
+ const providerConfig = providers?.[model.provider];
233
249
  if (providerConfig?.baseUrl) {
234
250
  return {
235
251
  baseUrl: providerConfig.baseUrl.replace(/\/$/, ""),
@@ -303,8 +319,17 @@ export class ModelProxy {
303
319
  this.pending.clear();
304
320
  this.streamText.clear();
305
321
  this.streamSetupSent.clear();
322
+ this.modelApiCache.clear();
306
323
  }
307
324
 
325
+ /** Clean up all tracking state for a request (pending, streamText, streamSetupSent). */
326
+ private cleanupRequest(id: string, stableStreamId?: string) {
327
+ const pending = this.pending.get(id);
328
+ if (pending) clearTimeout(pending.timer);
329
+ this.pending.delete(id);
330
+ this.streamText.delete(id);
331
+ if (stableStreamId) this.streamSetupSent.delete(stableStreamId);
332
+ }
308
333
 
309
334
  private sendResponse(res: import("node:http").ServerResponse, response: ProxyResponse) {
310
335
  res.writeHead(response.status, response.headers);
@@ -320,8 +345,12 @@ export class ModelProxy {
320
345
  res.end();
321
346
  return;
322
347
  }
323
- res.write(value);
324
- pump();
348
+ const ok = res.write(value);
349
+ if (ok) {
350
+ pump();
351
+ } else {
352
+ res.once("drain", pump);
353
+ }
325
354
  }).catch(() => {
326
355
  reader.releaseLock();
327
356
  res.end();
@@ -377,7 +406,7 @@ export class ModelProxy {
377
406
  }
378
407
 
379
408
  private async handleChatCompletion(rawBody: string, _api: string): Promise<ProxyResponse> {
380
- let body: { model: string; messages: unknown[]; stream?: boolean; temperature?: number; max_tokens?: number };
409
+ let body: { model: string; messages: unknown[]; stream?: boolean; temperature?: number; max_tokens?: number; tools?: unknown[]; tool_choice?: unknown };
381
410
  try {
382
411
  body = JSON.parse(rawBody);
383
412
  } catch {
@@ -410,7 +439,7 @@ export class ModelProxy {
410
439
  }
411
440
  return {
412
441
  type: "model_req", id, from: this.config.nodeId, to: candidate.routeNodeId, timestamp: Date.now(),
413
- payload: { model: modelId, provider: candidate.proxyModel?.provider, api: candidate.proxyModel?.api, messages, temperature: body.temperature, maxTokens: body.max_tokens, stream },
442
+ payload: { model: modelId, provider: candidate.proxyModel?.provider, api: candidate.proxyModel?.api, messages, tools: body.tools, toolChoice: body.tool_choice, temperature: body.temperature, maxTokens: body.max_tokens, stream },
414
443
  };
415
444
  };
416
445
  const frame = buildFrame(first, requestId);
@@ -423,7 +452,7 @@ export class ModelProxy {
423
452
  }
424
453
 
425
454
  private async handleResponses(rawBody: string): Promise<ProxyResponse> {
426
- let body: { model: string; input: unknown; stream?: boolean; temperature?: number; max_output_tokens?: number; instructions?: string };
455
+ let body: { model: string; input: unknown; stream?: boolean; temperature?: number; max_output_tokens?: number; instructions?: string; tools?: unknown[]; tool_choice?: unknown };
427
456
  try {
428
457
  body = JSON.parse(rawBody);
429
458
  } catch {
@@ -467,7 +496,7 @@ export class ModelProxy {
467
496
  }
468
497
  return {
469
498
  type: "model_req", id, from: this.config.nodeId, to: candidate.routeNodeId, timestamp: Date.now(),
470
- payload: { model: modelId, provider: candidate.proxyModel?.provider, api: candidate.proxyModel?.api, messages: inputItems, inputFormat: "responses", temperature: body.temperature, maxTokens: body.max_output_tokens, stream },
499
+ payload: { model: modelId, provider: candidate.proxyModel?.provider, api: candidate.proxyModel?.api, messages: inputItems, inputFormat: "responses", tools: body.tools, toolChoice: body.tool_choice, temperature: body.temperature, maxTokens: body.max_output_tokens, stream },
471
500
  };
472
501
  };
473
502
  const frame = buildFrame(first, requestId);
@@ -524,8 +553,7 @@ export class ModelProxy {
524
553
  const stableId = streamId ?? requestId;
525
554
 
526
555
  const timer = setTimeout(() => {
527
- this.pending.delete(requestId);
528
- this.streamText.delete(requestId);
556
+ this.cleanupRequest(requestId);
529
557
  this.peerManager.router.markFailed(requestId);
530
558
  this.tryStreamFailover(stableId, responseFormat, controller, encoder, model, failoverCandidates, buildFrame, `model request to "${targetNodeId}" timed out`);
531
559
  }, MODEL_TIMEOUT);
@@ -542,14 +570,14 @@ export class ModelProxy {
542
570
 
543
571
  // Emit setup events for responses API (only once per stream, keyed by stableId)
544
572
  if (responseFormat === "responses" && !this.streamSetupSent.has(stableId)) {
545
- this.enqueueResponsesStreamSetup(controller, encoder, stableId, model);
573
+ const hasTools = Array.isArray(frame.payload.tools) && frame.payload.tools.length > 0;
574
+ this.enqueueResponsesStreamSetup(controller, encoder, stableId, model, hasTools);
546
575
  this.streamSetupSent.add(stableId);
547
576
  }
548
577
 
549
578
  const sent = this.peerManager.sendTo(targetNodeId, frame);
550
579
  if (!sent) {
551
- this.pending.delete(requestId);
552
- clearTimeout(timer);
580
+ this.cleanupRequest(requestId);
553
581
  this.tryStreamFailover(stableId, responseFormat, controller, encoder, model, failoverCandidates, buildFrame, `cannot reach node "${targetNodeId}"`);
554
582
  }
555
583
  }
@@ -593,18 +621,23 @@ export class ModelProxy {
593
621
  }
594
622
 
595
623
  /** Emit responses API stream setup events (response.created → content_part.added). */
596
- private enqueueResponsesStreamSetup(controller: ReadableStreamDefaultController, encoder: TextEncoder, id: string, model: string) {
624
+ private enqueueResponsesStreamSetup(controller: ReadableStreamDefaultController, encoder: TextEncoder, id: string, model: string, hasTools = false) {
597
625
  const respId = `resp_${id}`;
598
- const msgId = `msg_${id}`;
599
626
  const now = Math.floor(Date.now() / 1000);
600
627
  const baseResp = { id: respId, object: "response", created_at: now, status: "in_progress", model, output: [] };
601
- const msgItem = { type: "message", id: msgId, role: "assistant", content: [], status: "in_progress" };
602
- const textPart = { type: "output_text", text: "" };
603
628
 
604
629
  controller.enqueue(encoder.encode(`event: response.created\ndata: ${JSON.stringify({ type: "response.created", response: baseResp })}\n\n`));
605
630
  controller.enqueue(encoder.encode(`event: response.in_progress\ndata: ${JSON.stringify({ type: "response.in_progress", response: baseResp })}\n\n`));
606
- controller.enqueue(encoder.encode(`event: response.output_item.added\ndata: ${JSON.stringify({ type: "response.output_item.added", output_index: 0, item: msgItem })}\n\n`));
607
- controller.enqueue(encoder.encode(`event: response.content_part.added\ndata: ${JSON.stringify({ type: "response.content_part.added", item_id: msgId, output_index: 0, content_index: 0, part: textPart })}\n\n`));
631
+
632
+ // When tools are present, skip pre-fabricated output_item/content_part events
633
+ // the real events (including function_call items) will be forwarded from the remote.
634
+ if (!hasTools) {
635
+ const msgId = `msg_${id}`;
636
+ const msgItem = { type: "message", id: msgId, role: "assistant", content: [], status: "in_progress" };
637
+ const textPart = { type: "output_text", text: "" };
638
+ controller.enqueue(encoder.encode(`event: response.output_item.added\ndata: ${JSON.stringify({ type: "response.output_item.added", output_index: 0, item: msgItem })}\n\n`));
639
+ controller.enqueue(encoder.encode(`event: response.content_part.added\ndata: ${JSON.stringify({ type: "response.content_part.added", item_id: msgId, output_index: 0, content_index: 0, part: textPart })}\n\n`));
640
+ }
608
641
  }
609
642
 
610
643
  /** Emit responses API stream completion events (output_text.done → response.completed). */
@@ -820,18 +853,17 @@ export class ModelProxy {
820
853
  // process the request and sent model_res instead of model_stream).
821
854
  if (pending.stream) {
822
855
  if (!frame.payload.success && pending.controller && pending.encoder) {
823
- clearTimeout(pending.timer);
824
- this.pending.delete(frame.id);
825
- this.streamText.delete(frame.id);
856
+ const stableId = pending.stableStreamId ?? frame.id;
826
857
  // Try failover if no content has been sent yet
827
858
  if (!pending.hasContent && pending.failoverCandidates?.length && pending.buildFrame) {
859
+ this.cleanupRequest(frame.id);
828
860
  this.tryStreamFailover(
829
- pending.stableStreamId ?? frame.id, pending.responseFormat, pending.controller, pending.encoder,
861
+ stableId, pending.responseFormat, pending.controller, pending.encoder,
830
862
  pending.model ?? "", pending.failoverCandidates, pending.buildFrame,
831
863
  `remote error: ${frame.payload.error}`,
832
864
  );
833
865
  } else {
834
- const stableId = pending.stableStreamId ?? frame.id;
866
+ this.cleanupRequest(frame.id, stableId);
835
867
  try {
836
868
  const errMsg = `[ClawMatrix] Remote error: ${frame.payload.error}`;
837
869
  if (pending.responseFormat === "responses") {
@@ -843,14 +875,12 @@ export class ModelProxy {
843
875
  }
844
876
  pending.controller.close();
845
877
  } catch { /* controller may already be closed */ }
846
- this.streamSetupSent.delete(stableId);
847
878
  }
848
879
  }
849
880
  return;
850
881
  }
851
882
 
852
- clearTimeout(pending.timer);
853
- this.pending.delete(frame.id);
883
+ this.cleanupRequest(frame.id);
854
884
  pending.resolve(frame.payload);
855
885
  }
856
886
 
@@ -870,9 +900,7 @@ export class ModelProxy {
870
900
  this.handleModelStreamChat(frame, pending);
871
901
  }
872
902
  } catch {
873
- clearTimeout(pending.timer);
874
- this.pending.delete(frame.id);
875
- this.streamText.delete(frame.id);
903
+ this.cleanupRequest(frame.id, pending.stableStreamId);
876
904
  }
877
905
  }
878
906
 
@@ -889,9 +917,7 @@ export class ModelProxy {
889
917
  pending.controller!.enqueue(pending.encoder!.encode(`data: ${JSON.stringify(finalChunk)}\n\n`));
890
918
  pending.controller!.enqueue(pending.encoder!.encode("data: [DONE]\n\n"));
891
919
  pending.controller!.close();
892
- clearTimeout(pending.timer);
893
- this.pending.delete(frame.id);
894
- this.streamSetupSent.delete(stableId);
920
+ this.cleanupRequest(frame.id, stableId);
895
921
  } else {
896
922
  // Use full deltaObj when available (carries tool_calls etc.), otherwise simple text delta
897
923
  const delta = frame.payload.deltaObj ?? { content: frame.payload.delta };
@@ -907,13 +933,27 @@ export class ModelProxy {
907
933
  const stableId = pending.stableStreamId ?? frame.id;
908
934
  const fullText = this.streamText.get(frame.id) ?? "";
909
935
  this.streamText.delete(frame.id);
910
- this.enqueueResponsesStreamDone(pending.controller!, pending.encoder!, stableId, pending.model ?? "", fullText, frame.payload.usage);
936
+
937
+ // If the remote forwarded the full response.completed event, emit it directly
938
+ const doneObj = frame.payload.deltaObj as { event?: string; data?: unknown } | undefined;
939
+ if (doneObj?.event === "response.completed" && doneObj.data) {
940
+ pending.controller!.enqueue(pending.encoder!.encode(`event: response.completed\ndata: ${JSON.stringify(doneObj.data)}\n\n`));
941
+ } else {
942
+ // Fallback: reconstruct text-only completion
943
+ this.enqueueResponsesStreamDone(pending.controller!, pending.encoder!, stableId, pending.model ?? "", fullText, frame.payload.usage);
944
+ }
911
945
  pending.controller!.close();
912
- clearTimeout(pending.timer);
913
- this.pending.delete(frame.id);
914
- this.streamSetupSent.delete(stableId);
946
+ this.cleanupRequest(frame.id, stableId);
915
947
  } else {
916
- // Accumulate text for done event
948
+ // Forward structured Responses API events (function_call, output_item, etc.)
949
+ const obj = frame.payload.deltaObj as { event?: string; data?: unknown } | undefined;
950
+ if (obj?.event && obj.data) {
951
+ pending.controller!.enqueue(pending.encoder!.encode(`event: ${obj.event}\ndata: ${JSON.stringify(obj.data)}\n\n`));
952
+ pending.hasContent = true;
953
+ return;
954
+ }
955
+
956
+ // Text delta
917
957
  this.streamText.set(frame.id, (this.streamText.get(frame.id) ?? "") + (frame.payload.delta ?? ""));
918
958
  const respStableId = pending.stableStreamId ?? frame.id;
919
959
  const evt = { type: "response.output_text.delta", item_id: `msg_${respStableId}`, output_index: 0, content_index: 0, delta: frame.payload.delta };
@@ -933,14 +973,14 @@ export class ModelProxy {
933
973
  } satisfies ModelStreamChunk);
934
974
  }
935
975
 
936
- private sendStreamDone(to: string, id: string, usage?: { inputTokens: number; outputTokens: number }) {
976
+ private sendStreamDone(to: string, id: string, usage?: { inputTokens: number; outputTokens: number }, deltaObj?: unknown) {
937
977
  this.peerManager.sendTo(to, {
938
978
  type: "model_stream",
939
979
  id,
940
980
  from: this.config.nodeId,
941
981
  to,
942
982
  timestamp: Date.now(),
943
- payload: { delta: "", done: true, usage },
983
+ payload: { delta: "", done: true, usage, ...(deltaObj !== undefined && { deltaObj }) },
944
984
  } satisfies ModelStreamChunk);
945
985
  }
946
986
 
@@ -967,7 +1007,12 @@ export class ModelProxy {
967
1007
 
968
1008
  try {
969
1009
  const endpoint = this.resolveModelEndpoint(model);
970
- const isResponsesApi = endpoint.api === "openai-responses" || endpoint.api === "openai-codex-responses";
1010
+ // Use payload.api override from requesting side, or cached API from previous auto-detection
1011
+ const cached = this.modelApiCache.get(model.id);
1012
+ const cachedApi = (cached && Date.now() - cached.ts < ModelProxy.MODEL_API_CACHE_TTL) ? cached.api : undefined;
1013
+ if (cached && !cachedApi) this.modelApiCache.delete(model.id); // expired
1014
+ const effectiveApi = payload.api ?? cachedApi ?? endpoint.api;
1015
+ const isResponsesApi = effectiveApi === "openai-responses" || effectiveApi === "openai-codex-responses";
971
1016
  const path = isResponsesApi ? "/responses" : "/chat/completions";
972
1017
  const url = `${endpoint.baseUrl}${path}`;
973
1018
  const headers: Record<string, string> = { "Content-Type": "application/json" };
@@ -996,6 +1041,8 @@ export class ModelProxy {
996
1041
  stream: payload.stream,
997
1042
  temperature: payload.temperature,
998
1043
  max_output_tokens: payload.maxTokens,
1044
+ ...(payload.tools && { tools: payload.tools }),
1045
+ ...(payload.toolChoice !== undefined && { tool_choice: payload.toolChoice }),
999
1046
  };
1000
1047
  } else {
1001
1048
  const messages = srcFormat === "chat"
@@ -1008,6 +1055,8 @@ export class ModelProxy {
1008
1055
  max_tokens: payload.maxTokens,
1009
1056
  stream: payload.stream,
1010
1057
  ...(payload.stream ? { stream_options: { include_usage: true } } : {}),
1058
+ ...(payload.tools && { tools: payload.tools }),
1059
+ ...(payload.toolChoice !== undefined && { tool_choice: payload.toolChoice }),
1011
1060
  };
1012
1061
  }
1013
1062
 
@@ -1031,6 +1080,8 @@ export class ModelProxy {
1031
1080
  let buffer = "";
1032
1081
  let lastUsage: { inputTokens: number; outputTokens: number } | undefined;
1033
1082
  let streamDone = false;
1083
+ let contentSent = false;
1084
+ let completedEvent: unknown = undefined;
1034
1085
 
1035
1086
  while (!streamDone) {
1036
1087
  const { done, value } = await reader.read();
@@ -1053,7 +1104,6 @@ export class ModelProxy {
1053
1104
  if (!line.startsWith("data: ")) continue;
1054
1105
  const data = line.slice(6).trim();
1055
1106
  if (data === "[DONE]") {
1056
- this.sendStreamDone(from, id, lastUsage);
1057
1107
  streamDone = true;
1058
1108
  break;
1059
1109
  }
@@ -1067,33 +1117,37 @@ export class ModelProxy {
1067
1117
  const delta = parsed.delta || "";
1068
1118
  if (delta) {
1069
1119
  this.sendStreamDelta(from, id, delta);
1120
+ contentSent = true;
1070
1121
  }
1122
+ } else if (
1123
+ evtType === "response.output_item.added" ||
1124
+ evtType === "response.output_item.done" ||
1125
+ evtType === "response.content_part.added" ||
1126
+ evtType === "response.content_part.done" ||
1127
+ evtType === "response.output_text.done" ||
1128
+ evtType === "response.function_call_arguments.delta" ||
1129
+ evtType === "response.function_call_arguments.done"
1130
+ ) {
1131
+ // Forward structured Responses API events via deltaObj
1132
+ this.sendStreamDelta(from, id, "", { event: evtType, data: parsed });
1133
+ contentSent = true;
1071
1134
  } else if (evtType === "response.completed") {
1072
1135
  const usage = parsed.response?.usage;
1073
- if (usage) {
1074
- lastUsage = {
1075
- inputTokens: usage.input_tokens ?? usage.prompt_tokens ?? 0,
1076
- outputTokens: usage.output_tokens ?? usage.completion_tokens ?? 0,
1077
- };
1078
- }
1079
- this.sendStreamDone(from, id, lastUsage);
1136
+ lastUsage = parseUsage(usage) ?? lastUsage;
1137
+ completedEvent = { event: evtType, data: parsed };
1080
1138
  streamDone = true;
1081
1139
  break;
1082
1140
  }
1083
1141
  } else {
1084
1142
  // Chat completions format
1085
- if (parsed.usage) {
1086
- lastUsage = {
1087
- inputTokens: parsed.usage.prompt_tokens,
1088
- outputTokens: parsed.usage.completion_tokens,
1089
- };
1090
- }
1143
+ lastUsage = parseUsage(parsed.usage) ?? lastUsage;
1091
1144
  const d = parsed.choices?.[0]?.delta;
1092
1145
  const delta = d?.content || d?.reasoning_content || "";
1093
1146
  // Pass full delta object when it contains tool_calls or other structured data
1094
1147
  const hasStructured = d?.tool_calls || d?.refusal != null;
1095
1148
  if (delta || hasStructured) {
1096
1149
  this.sendStreamDelta(from, id, delta, hasStructured ? d : undefined);
1150
+ contentSent = true;
1097
1151
  }
1098
1152
  }
1099
1153
  } catch {
@@ -1102,9 +1156,30 @@ export class ModelProxy {
1102
1156
  currentEvent = "";
1103
1157
  }
1104
1158
  }
1105
- // If the upstream closed without sending [DONE] or response.completed,
1106
- // send a completion frame so the requesting side doesn't hang.
1107
- if (!streamDone) {
1159
+
1160
+ // Responses API stream produced no content fall back to chat completions
1161
+ if (isResponsesApi && !contentSent && !cachedApi) {
1162
+ debug("model_req", `responses API stream produced no content for "${model.id}", retrying with chat completions`);
1163
+ const chatResult = await this.retryWithChatCompletions(endpoint, modelField, payload, headers);
1164
+ if (chatResult) {
1165
+ this.modelApiCache.set(model.id, { api: "openai-completions", ts: Date.now() });
1166
+ debug("model_req", `cached "${model.id}" as openai-completions (stream fallback)`);
1167
+ if (chatResult.content) {
1168
+ this.sendStreamDelta(from, id, chatResult.content);
1169
+ }
1170
+ this.sendStreamDone(from, id, chatResult.usage);
1171
+ } else if (completedEvent) {
1172
+ this.sendStreamDone(from, id, lastUsage, completedEvent);
1173
+ } else {
1174
+ this.sendStreamDone(from, id, lastUsage);
1175
+ }
1176
+ } else if (completedEvent) {
1177
+ this.sendStreamDone(from, id, lastUsage, completedEvent);
1178
+ } else if (!streamDone) {
1179
+ // Upstream closed without sending [DONE] or response.completed
1180
+ this.sendStreamDone(from, id, lastUsage);
1181
+ } else {
1182
+ // Chat completions [DONE] received
1108
1183
  this.sendStreamDone(from, id, lastUsage);
1109
1184
  }
1110
1185
  } finally {
@@ -1112,12 +1187,31 @@ export class ModelProxy {
1112
1187
  }
1113
1188
  } else {
1114
1189
  // Non-streaming response
1115
- const result = await response.json();
1190
+ const responseText = await response.text();
1191
+ let result: Record<string, unknown>;
1192
+ let chatFallbackResult: Awaited<ReturnType<ModelProxy["retryWithChatCompletions"]>> = null;
1193
+ try {
1194
+ result = JSON.parse(responseText);
1195
+ } catch {
1196
+ // Upstream returned non-JSON (e.g. SSE in non-stream mode) — try chat completions fallback
1197
+ if (!cachedApi && isResponsesApi) {
1198
+ debug("model_req", `responses API returned non-JSON for "${model.id}", retrying with chat completions`);
1199
+ chatFallbackResult = await this.retryWithChatCompletions(endpoint, modelField, payload, headers);
1200
+ if (chatFallbackResult) {
1201
+ this.modelApiCache.set(model.id, { api: "openai-completions", ts: Date.now() });
1202
+ debug("model_req", `cached "${model.id}" as openai-completions (non-JSON fallback)`);
1203
+ }
1204
+ }
1205
+ if (!chatFallbackResult) throw new Error(`Upstream returned non-JSON: ${responseText.slice(0, 100)}`);
1206
+ result = {}; // unused — chatFallbackResult takes precedence
1207
+ }
1116
1208
  let content: string;
1117
1209
  let message: unknown | undefined;
1118
1210
  let usage: { inputTokens: number; outputTokens: number } | undefined;
1119
1211
 
1120
- if (isResponsesApi) {
1212
+ if (chatFallbackResult) {
1213
+ ({ content, message, usage } = chatFallbackResult);
1214
+ } else if (isResponsesApi) {
1121
1215
  // Responses API: extract text from output[].content[].text
1122
1216
  content = "";
1123
1217
  const output = result.output as { type?: string; content?: { type?: string; text?: string }[] }[] | undefined;
@@ -1130,13 +1224,27 @@ export class ModelProxy {
1130
1224
  }
1131
1225
  }
1132
1226
  }
1133
- // Carry full output array for structured data (function_call items, etc.)
1134
- message = result.output;
1135
- if (result.usage) {
1136
- usage = {
1137
- inputTokens: result.usage.input_tokens ?? result.usage.prompt_tokens ?? 0,
1138
- outputTokens: result.usage.output_tokens ?? result.usage.completion_tokens ?? 0,
1139
- };
1227
+
1228
+ // Auto-detect: if Responses API returned empty output but produced tokens,
1229
+ // the upstream adapter likely doesn't support Responses API properly.
1230
+ // Retry with chat completions and cache the result.
1231
+ const parsedUsage = parseUsage(result.usage as Record<string, number> | undefined);
1232
+ const hasMessage = Array.isArray(output) && output.some((o: { type?: string }) => o.type === "message");
1233
+ if (!hasMessage && (parsedUsage?.outputTokens ?? 0) > 0 && !cachedApi) {
1234
+ debug("model_req", `responses API returned empty output for "${model.id}" (output_tokens=${parsedUsage!.outputTokens}), retrying with chat completions`);
1235
+ const chatResult = await this.retryWithChatCompletions(endpoint, modelField, payload, headers);
1236
+ if (chatResult) {
1237
+ this.modelApiCache.set(model.id, { api: "openai-completions", ts: Date.now() });
1238
+ debug("model_req", `cached "${model.id}" as openai-completions`);
1239
+ ({ content, message, usage } = chatResult);
1240
+ } else {
1241
+ message = result.output;
1242
+ usage = parsedUsage;
1243
+ }
1244
+ } else {
1245
+ // Carry full output array for structured data (function_call items, etc.)
1246
+ message = result.output;
1247
+ usage = parsedUsage;
1140
1248
  }
1141
1249
  } else {
1142
1250
  // Chat completions format
@@ -1146,12 +1254,7 @@ export class ModelProxy {
1146
1254
  if (msg?.tool_calls || msg?.refusal != null || msg?.function_call) {
1147
1255
  message = msg;
1148
1256
  }
1149
- if (result.usage) {
1150
- usage = {
1151
- inputTokens: result.usage.prompt_tokens,
1152
- outputTokens: result.usage.completion_tokens,
1153
- };
1154
- }
1257
+ usage = parseUsage(result.usage);
1155
1258
  }
1156
1259
 
1157
1260
  this.peerManager.sendTo(from, {
@@ -1182,4 +1285,44 @@ export class ModelProxy {
1182
1285
  } satisfies ModelResponse);
1183
1286
  }
1184
1287
  }
1288
+
1289
+ /** Retry a model request using chat completions format (fallback from Responses API). */
1290
+ private async retryWithChatCompletions(
1291
+ endpoint: { baseUrl: string; apiKey?: string; direct: boolean; api: string },
1292
+ modelField: string,
1293
+ payload: ModelRequest["payload"],
1294
+ headers: Record<string, string>,
1295
+ ): Promise<{ content: string; message?: unknown; usage?: { inputTokens: number; outputTokens: number } } | null> {
1296
+ try {
1297
+ const srcFormat = payload.inputFormat ?? "chat";
1298
+ const messages = srcFormat === "chat"
1299
+ ? payload.messages
1300
+ : ModelProxy.normalizeResponsesInput(payload.messages);
1301
+ const chatBody: Record<string, unknown> = {
1302
+ model: modelField,
1303
+ messages,
1304
+ temperature: payload.temperature,
1305
+ max_tokens: payload.maxTokens,
1306
+ stream: false,
1307
+ ...(payload.tools && { tools: payload.tools }),
1308
+ ...(payload.toolChoice !== undefined && { tool_choice: payload.toolChoice }),
1309
+ };
1310
+ const chatUrl = `${endpoint.baseUrl}/chat/completions`;
1311
+ const chatResp = await fetch(chatUrl, {
1312
+ method: "POST",
1313
+ headers,
1314
+ body: JSON.stringify(chatBody),
1315
+ });
1316
+ if (!chatResp.ok) return null;
1317
+ const chatResult = await chatResp.json();
1318
+ const msg = chatResult.choices?.[0]?.message;
1319
+ const content = msg?.content || msg?.reasoning_content || "";
1320
+ const message = (msg?.tool_calls || msg?.refusal != null || msg?.function_call) ? msg : undefined;
1321
+ const usage = parseUsage(chatResult.usage);
1322
+ return { content, message, usage };
1323
+ } catch (err) {
1324
+ debug("model_req", `retryWithChatCompletions failed for "${modelField}": ${err instanceof Error ? err.message : String(err)}`);
1325
+ return null;
1326
+ }
1327
+ }
1185
1328
  }
@@ -213,13 +213,16 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
213
213
  });
214
214
  });
215
215
 
216
- ws.addEventListener("error", () => {
217
- this.scheduleReconnect(peer);
218
- });
216
+ let reconnectScheduled = false;
217
+ const tryReconnect = () => {
218
+ if (!reconnectScheduled) {
219
+ reconnectScheduled = true;
220
+ this.scheduleReconnect(peer);
221
+ }
222
+ };
219
223
 
220
- ws.addEventListener("close", () => {
221
- this.scheduleReconnect(peer);
222
- });
224
+ ws.addEventListener("error", tryReconnect);
225
+ ws.addEventListener("close", tryReconnect);
223
226
  }
224
227
 
225
228
  private scheduleReconnect(peer: PeerConfig) {
package/src/types.ts CHANGED
@@ -101,6 +101,8 @@ export interface ModelRequest extends ClusterFrame {
101
101
  /** Format of `messages`: "chat" = OpenAI chat completions, "responses" = OpenAI Responses API input items.
102
102
  * Defaults to "chat" for backward compatibility. */
103
103
  inputFormat?: "chat" | "responses";
104
+ tools?: unknown[];
105
+ toolChoice?: unknown;
104
106
  temperature?: number;
105
107
  maxTokens?: number;
106
108
  stream: boolean;