clawmatrix 0.1.8 → 0.1.12
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/BOOTSTRAP.md +8 -27
- package/README.md +5 -17
- package/package.json +3 -2
- package/src/cli.ts +50 -25
- package/src/cluster-service.ts +9 -2
- package/src/config.ts +18 -0
- package/src/connection.ts +1 -0
- package/src/debug.ts +5 -0
- package/src/index.ts +36 -13
- package/src/local-tools.ts +176 -0
- package/src/model-proxy.ts +228 -75
- package/src/peer-manager.ts +32 -5
- package/src/router.ts +18 -3
- package/src/tool-proxy.ts +4 -1
- package/src/types.ts +16 -0
package/BOOTSTRAP.md
CHANGED
|
@@ -92,15 +92,16 @@ openclaw clawmatrix status
|
|
|
92
92
|
"tags": ["office", "gpu"],
|
|
93
93
|
"toolProxy": {
|
|
94
94
|
"enabled": true,
|
|
95
|
-
"allow": ["
|
|
96
|
-
"deny": [
|
|
95
|
+
"allow": ["*"],
|
|
96
|
+
"deny": []
|
|
97
97
|
}
|
|
98
98
|
}
|
|
99
99
|
```
|
|
100
100
|
|
|
101
101
|
注意事项:
|
|
102
|
-
- `models`
|
|
103
|
-
-
|
|
102
|
+
- `models` 里的 `provider` 必须与 OpenClaw `models.providers` 中的 key 一致,ClawMatrix 会自动读取对应的 `baseUrl` 和 `apiKey`
|
|
103
|
+
- 如果需要覆盖(比如用不同的 API 地址),可以显式设置 `baseUrl` 和 `apiKey`
|
|
104
|
+
- ClawMatrix 直接调模型 API,不经过 OpenClaw Gateway 的 agent 系统
|
|
104
105
|
- `toolProxy.enabled: true` 才会接受远程工具调用
|
|
105
106
|
- `toolProxy.deny` 优先于 `allow`,建议禁用高风险工具
|
|
106
107
|
|
|
@@ -154,13 +155,13 @@ openclaw clawmatrix status
|
|
|
154
155
|
| `listenPort` | number | `19000` | 入站 WebSocket 端口 |
|
|
155
156
|
| `peers` | array | `[]` | 要连接的 peer:`{ nodeId, url }` |
|
|
156
157
|
| `agents` | array | `[]` | 本节点提供的 Agent:`{ id, description, tags }` |
|
|
157
|
-
| `models` | array | `[]` | 本节点共享给集群的模型:`{ id, provider
|
|
158
|
+
| `models` | array | `[]` | 本节点共享给集群的模型:`{ id, provider }`(自动读取 OpenClaw provider 的 baseUrl/apiKey,可选覆盖)|
|
|
158
159
|
| `proxyModels` | array | `[]` | 从集群消费的远程模型:`{ id, nodeId?, description? }` |
|
|
159
160
|
| `tags` | array | `[]` | 自由标签,用于能力路由 |
|
|
160
161
|
| `proxyPort` | number | `19001` | 本地模型代理 HTTP 端口 |
|
|
161
162
|
| `handoffTimeout` | number | `600000` | Handoff 超时(毫秒,默认 10 分钟) |
|
|
162
163
|
| `toolProxy.enabled` | boolean | `false` | 允许远程工具执行 |
|
|
163
|
-
| `toolProxy.allow` | array | `[]` |
|
|
164
|
+
| `toolProxy.allow` | array | `[]` | 允许的工具名,`["*"]` 或 `[]` 表示全部。`exec`/`read`/`write`/`edit` 本地执行,其余走 Gateway |
|
|
164
165
|
| `toolProxy.deny` | array | `[]` | 禁止的工具名(优先于 allow) |
|
|
165
166
|
| `toolProxy.maxOutputBytes` | number | `1048576` | 单次响应最大字节数 |
|
|
166
167
|
|
|
@@ -181,26 +182,6 @@ openclaw clawmatrix status
|
|
|
181
182
|
`target` 参数支持 Agent ID(如 `"coder"`)或标签查询(如 `"tags:coding"`)。
|
|
182
183
|
`node` 参数支持 nodeId(如 `"office-01"`)或标签查询(如 `"tags:gpu"`)。
|
|
183
184
|
|
|
184
|
-
## 前置配置:开启 chatCompletions 端点
|
|
185
|
-
|
|
186
|
-
**所有共享模型的节点都必须配置此项**。ClawMatrix 的模型代理通过 OpenClaw Gateway 的 `/v1/chat/completions` HTTP 端点转发请求,该端点默认关闭。
|
|
187
|
-
|
|
188
|
-
在 `openclaw.json` 的 `gateway` 字段下添加:
|
|
189
|
-
|
|
190
|
-
```json
|
|
191
|
-
{
|
|
192
|
-
"gateway": {
|
|
193
|
-
"http": {
|
|
194
|
-
"endpoints": {
|
|
195
|
-
"chatCompletions": { "enabled": true }
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
```
|
|
201
|
-
|
|
202
|
-
如果不开启,模型代理请求会返回 404 错误。
|
|
203
|
-
|
|
204
185
|
## 前置配置:注册集群模型到 OpenClaw
|
|
205
186
|
|
|
206
187
|
消费远程模型的节点需要在 `models.providers` 中注册,否则 `/models` 命令看不到集群模型。
|
|
@@ -249,7 +230,7 @@ openclaw clawmatrix status
|
|
|
249
230
|
|
|
250
231
|
**模型代理不工作**:
|
|
251
232
|
- 确认远程节点的 `models` 中声明了该模型
|
|
252
|
-
-
|
|
233
|
+
- 确认该模型的 `provider` 与 OpenClaw `models.providers` 中的 key 一致(ClawMatrix 会自动读取 baseUrl 和 apiKey)
|
|
253
234
|
- 确认本节点使用 `clawmatrix/<模型ID>` 格式引用模型
|
|
254
235
|
- 如果用了 `proxyModels.nodeId`,确认该节点在线
|
|
255
236
|
|
package/README.md
CHANGED
|
@@ -61,7 +61,7 @@ openclaw plugins install clawmatrix
|
|
|
61
61
|
{ "id": "claude-sonnet", "provider": "anthropic" },
|
|
62
62
|
{ "id": "deepseek-coder", "provider": "ollama" }
|
|
63
63
|
],
|
|
64
|
-
"toolProxy": { "enabled": true, "allow": ["
|
|
64
|
+
"toolProxy": { "enabled": true, "allow": ["*"] }
|
|
65
65
|
}
|
|
66
66
|
```
|
|
67
67
|
|
|
@@ -77,19 +77,7 @@ openclaw plugins install clawmatrix
|
|
|
77
77
|
}
|
|
78
78
|
```
|
|
79
79
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
```json
|
|
83
|
-
{
|
|
84
|
-
"gateway": {
|
|
85
|
-
"http": {
|
|
86
|
-
"endpoints": {
|
|
87
|
-
"chatCompletions": { "enabled": true }
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
```
|
|
80
|
+
**重要**:共享模型的节点只需在 `models` 中声明 `{ id, provider }`,ClawMatrix 会自动从 OpenClaw 的 `models.providers` 读取对应的 `baseUrl` 和 `apiKey`,直接调用模型 API(不经过 OpenClaw Gateway 的 agent 系统)。
|
|
93
81
|
|
|
94
82
|
消费远程模型的节点需要在 `models.providers` 中注册(以 nodeId 为 key,baseUrl 指向 `http://127.0.0.1:19001`),详见 [BOOTSTRAP.md](BOOTSTRAP.md)。
|
|
95
83
|
|
|
@@ -129,14 +117,14 @@ openclaw clawmatrix status
|
|
|
129
117
|
| `listenPort` | number | `19000` | 入站 WS 端口 |
|
|
130
118
|
| `peers` | array | `[]` | 要连接的 peer:`{ nodeId, url }` |
|
|
131
119
|
| `agents` | array | `[]` | 本节点 Agent:`{ id, description, tags }` |
|
|
132
|
-
| `models` | array | `[]` | 共享给集群的模型:`{ id, provider }`
|
|
120
|
+
| `models` | array | `[]` | 共享给集群的模型:`{ id, provider }`(自动读取 OpenClaw provider 配置,可选 `baseUrl`/`apiKey` 覆盖)|
|
|
133
121
|
| `proxyModels` | array | `[]` | 要消费的远程模型:`{ id, nodeId? }` |
|
|
134
122
|
| `tags` | array | `[]` | 自由标签 |
|
|
135
123
|
| `proxyPort` | number | `19001` | 本地模型代理端口 |
|
|
136
124
|
| `handoffTimeout` | number | `600000` | Handoff 超时(ms) |
|
|
137
125
|
| `toolProxy.enabled` | boolean | `false` | 允许远程工具执行 |
|
|
138
|
-
| `toolProxy.allow` | array | `[]` |
|
|
139
|
-
| `toolProxy.deny` | array | `[]` |
|
|
126
|
+
| `toolProxy.allow` | array | `[]` | 允许的工具名,`["*"]` 或 `[]` 表示全部。`exec`/`read`/`write`/`edit` 本地执行,其余走 Gateway |
|
|
127
|
+
| `toolProxy.deny` | array | `[]` | 禁止的工具(优先于 allow) |
|
|
140
128
|
|
|
141
129
|
## 架构
|
|
142
130
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "clawmatrix",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.12",
|
|
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",
|
|
@@ -28,7 +28,8 @@
|
|
|
28
28
|
},
|
|
29
29
|
"scripts": {
|
|
30
30
|
"test": "bun test",
|
|
31
|
-
"prepublishOnly": "bun test"
|
|
31
|
+
"prepublishOnly": "bun test",
|
|
32
|
+
"release": "bunx bumpp"
|
|
32
33
|
},
|
|
33
34
|
"dependencies": {
|
|
34
35
|
"ws": "^8.19.0",
|
package/src/cli.ts
CHANGED
|
@@ -62,21 +62,32 @@ export const registerClusterCli = ({ program }: { program: Command }) => {
|
|
|
62
62
|
return;
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
// Style helpers
|
|
66
|
+
const bold = (s: string) => `\x1b[1m${s}\x1b[22m`;
|
|
67
|
+
const dim = (s: string) => `\x1b[2m${s}\x1b[22m`;
|
|
68
|
+
const green = (s: string) => `\x1b[32m${s}\x1b[39m`;
|
|
69
|
+
const red = (s: string) => `\x1b[31m${s}\x1b[39m`;
|
|
70
|
+
const cyan = (s: string) => `\x1b[36m${s}\x1b[39m`;
|
|
71
|
+
const yellow = (s: string) => `\x1b[33m${s}\x1b[39m`;
|
|
72
|
+
const bar = dim("│");
|
|
73
|
+
const lbl = (text: string) => dim(text.padEnd(13));
|
|
74
|
+
|
|
65
75
|
const agents = data.agents as Array<{ id: string }>;
|
|
66
76
|
const models = data.models as Array<{ id: string }>;
|
|
67
77
|
const tags = data.tags as string[];
|
|
68
78
|
|
|
69
|
-
|
|
70
|
-
console.log(
|
|
71
|
-
console.log("");
|
|
72
|
-
console.log(`
|
|
79
|
+
// Local node
|
|
80
|
+
console.log();
|
|
81
|
+
console.log(` ${cyan("◆")} ${bold("ClawMatrix Cluster")}`);
|
|
82
|
+
console.log(` ${bar}`);
|
|
83
|
+
console.log(` ${bar} ${lbl("Node")}${bold(String(data.nodeId))}`);
|
|
73
84
|
if (tags.length > 0) {
|
|
74
|
-
console.log(` Tags
|
|
85
|
+
console.log(` ${bar} ${lbl("Tags")}${tags.join(dim(", "))}`);
|
|
75
86
|
}
|
|
76
|
-
console.log(` Listen
|
|
77
|
-
console.log(` Model
|
|
78
|
-
console.log(` Agents
|
|
79
|
-
console.log(` Models
|
|
87
|
+
console.log(` ${bar} ${lbl("Listen")}${data.listen !== false ? `:${data.listen}` : dim("disabled")}`);
|
|
88
|
+
console.log(` ${bar} ${lbl("Model Proxy")}:${data.proxyPort}`);
|
|
89
|
+
console.log(` ${bar} ${lbl("Agents")}${agents.map((a) => a.id).join(dim(", ")) || dim("–")}`);
|
|
90
|
+
console.log(` ${bar} ${lbl("Models")}${models.map((m) => m.id).join(dim(", ")) || dim("–")}`);
|
|
80
91
|
|
|
81
92
|
const peers = data.peers as Array<{
|
|
82
93
|
nodeId: string;
|
|
@@ -87,34 +98,48 @@ export const registerClusterCli = ({ program }: { program: Command }) => {
|
|
|
87
98
|
latencyMs: number;
|
|
88
99
|
}>;
|
|
89
100
|
|
|
90
|
-
const connected = peers.filter((p) => p.connected).length;
|
|
91
|
-
console.log("");
|
|
92
101
|
if (!peers || peers.length === 0) {
|
|
93
|
-
console.log(
|
|
94
|
-
console.log("");
|
|
102
|
+
console.log(` ${bar}`);
|
|
103
|
+
console.log(` ${dim("◇")} ${dim("No peers discovered")}`);
|
|
104
|
+
console.log();
|
|
95
105
|
return;
|
|
96
106
|
}
|
|
97
107
|
|
|
98
|
-
|
|
99
|
-
|
|
108
|
+
const connected = peers.filter((p) => p.connected).length;
|
|
109
|
+
const countStr = `${connected}/${peers.length} connected`;
|
|
110
|
+
const countColor = connected === peers.length ? green : connected > 0 ? yellow : red;
|
|
111
|
+
|
|
112
|
+
console.log(` ${bar}`);
|
|
113
|
+
console.log(` ${cyan("◆")} ${bold("Peers")} ${countColor(countStr)}`);
|
|
114
|
+
console.log(` ${bar}`);
|
|
115
|
+
|
|
116
|
+
for (let i = 0; i < peers.length; i++) {
|
|
117
|
+
const peer = peers[i];
|
|
118
|
+
const dot = peer.connected ? green("●") : red("○");
|
|
119
|
+
const latency = peer.connected && peer.latencyMs > 0 ? dim(` ${peer.latencyMs}ms`) : "";
|
|
120
|
+
const status = peer.connected ? "" : red(" disconnected");
|
|
121
|
+
console.log(` ${bar} ${dot} ${bold(peer.nodeId)}${status}${latency}`);
|
|
100
122
|
|
|
101
|
-
for (const peer of peers) {
|
|
102
|
-
const status = peer.connected ? "connected" : "unreachable";
|
|
103
|
-
const latency = peer.latencyMs > 0 ? `, ${peer.latencyMs}ms` : "";
|
|
104
|
-
console.log(` ${peer.nodeId} (${status}${latency})`);
|
|
105
123
|
if (peer.tags.length > 0) {
|
|
106
|
-
console.log(` Tags
|
|
124
|
+
console.log(` ${bar} ${lbl("Tags")}${peer.tags.join(dim(", "))}`);
|
|
107
125
|
}
|
|
108
|
-
const peerAgents = peer.agents.map((a) => a.id).join(", ");
|
|
126
|
+
const peerAgents = peer.agents.map((a) => a.id).join(dim(", "));
|
|
109
127
|
if (peerAgents) {
|
|
110
|
-
console.log(` Agents
|
|
128
|
+
console.log(` ${bar} ${lbl("Agents")}${peerAgents}`);
|
|
111
129
|
}
|
|
112
|
-
const peerModels = peer.models.map((m) => m.id).join(", ");
|
|
130
|
+
const peerModels = peer.models.map((m) => m.id).join(dim(", "));
|
|
113
131
|
if (peerModels) {
|
|
114
|
-
console.log(` Models
|
|
132
|
+
console.log(` ${bar} ${lbl("Models")}${peerModels}`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (i < peers.length - 1) {
|
|
136
|
+
console.log(` ${bar}`);
|
|
115
137
|
}
|
|
116
138
|
}
|
|
117
|
-
|
|
139
|
+
|
|
140
|
+
console.log(` ${bar}`);
|
|
141
|
+
console.log(` ${dim("◇")}`);
|
|
142
|
+
console.log();
|
|
118
143
|
});
|
|
119
144
|
|
|
120
145
|
cmd
|
package/src/cluster-service.ts
CHANGED
|
@@ -6,6 +6,7 @@ import type {
|
|
|
6
6
|
} from "openclaw/plugin-sdk";
|
|
7
7
|
import type { ClawMatrixConfig } from "./config.ts";
|
|
8
8
|
import { spawnProcess } from "./compat.ts";
|
|
9
|
+
import { debug } from "./debug.ts";
|
|
9
10
|
import { PeerManager } from "./peer-manager.ts";
|
|
10
11
|
import { HandoffManager } from "./handoff.ts";
|
|
11
12
|
import { ModelProxy } from "./model-proxy.ts";
|
|
@@ -50,7 +51,7 @@ export class ClusterRuntime {
|
|
|
50
51
|
const gatewayInfo = resolveGatewayInfo(openclawConfig);
|
|
51
52
|
this.peerManager = new PeerManager(config);
|
|
52
53
|
this.handoffManager = new HandoffManager(config, this.peerManager);
|
|
53
|
-
this.modelProxy = new ModelProxy(config, this.peerManager, gatewayInfo);
|
|
54
|
+
this.modelProxy = new ModelProxy(config, this.peerManager, gatewayInfo, openclawConfig);
|
|
54
55
|
this.toolProxy = new ToolProxy(config, this.peerManager, gatewayInfo);
|
|
55
56
|
}
|
|
56
57
|
|
|
@@ -88,6 +89,9 @@ export class ClusterRuntime {
|
|
|
88
89
|
}
|
|
89
90
|
|
|
90
91
|
private dispatchFrame(frame: AnyClusterFrame) {
|
|
92
|
+
if (frame.type.startsWith("model_")) {
|
|
93
|
+
debug("dispatch", `${frame.type} id=${frame.id} from=${(frame as ClusterFrame).from}`);
|
|
94
|
+
}
|
|
91
95
|
switch (frame.type) {
|
|
92
96
|
case "handoff_req":
|
|
93
97
|
this.handoffManager.handleRequest(frame as HandoffRequest).catch((err) => {
|
|
@@ -143,10 +147,13 @@ export class ClusterRuntime {
|
|
|
143
147
|
}
|
|
144
148
|
|
|
145
149
|
// Fire-and-forget: inject message via openclaw agent CLI
|
|
146
|
-
spawnProcess(["openclaw", "agent", "--agent", agent.id, "--message", message], {
|
|
150
|
+
const proc = spawnProcess(["openclaw", "agent", "--agent", agent.id, "--message", message], {
|
|
147
151
|
stdout: "ignore",
|
|
148
152
|
stderr: "ignore",
|
|
149
153
|
});
|
|
154
|
+
proc.exited.catch((err) => {
|
|
155
|
+
this.logger.error(`[clawmatrix] Failed to inject message to agent "${agent.id}": ${err}`);
|
|
156
|
+
});
|
|
150
157
|
}
|
|
151
158
|
}
|
|
152
159
|
|
package/src/config.ts
CHANGED
|
@@ -6,6 +6,21 @@ const AgentInfoSchema = z.object({
|
|
|
6
6
|
tags: z.array(z.string()).default([]),
|
|
7
7
|
});
|
|
8
8
|
|
|
9
|
+
const ModelCompatSchema = z.object({
|
|
10
|
+
supportsTools: z.boolean().optional(),
|
|
11
|
+
supportsStore: z.boolean().optional(),
|
|
12
|
+
supportsDeveloperRole: z.boolean().optional(),
|
|
13
|
+
supportsReasoningEffort: z.boolean().optional(),
|
|
14
|
+
supportsUsageInStreaming: z.boolean().optional(),
|
|
15
|
+
supportsStrictMode: z.boolean().optional(),
|
|
16
|
+
maxTokensField: z.enum(["max_completion_tokens", "max_tokens"]).optional(),
|
|
17
|
+
thinkingFormat: z.enum(["openai", "zai", "qwen"]).optional(),
|
|
18
|
+
requiresToolResultName: z.boolean().optional(),
|
|
19
|
+
requiresAssistantAfterToolResult: z.boolean().optional(),
|
|
20
|
+
requiresThinkingAsText: z.boolean().optional(),
|
|
21
|
+
requiresMistralToolIds: z.boolean().optional(),
|
|
22
|
+
}).optional();
|
|
23
|
+
|
|
9
24
|
const ModelParamsSchema = {
|
|
10
25
|
api: z.enum([
|
|
11
26
|
"openai-completions", "openai-responses", "openai-codex-responses",
|
|
@@ -22,12 +37,15 @@ const ModelParamsSchema = {
|
|
|
22
37
|
cacheRead: z.number(),
|
|
23
38
|
cacheWrite: z.number(),
|
|
24
39
|
}).optional(),
|
|
40
|
+
compat: ModelCompatSchema,
|
|
25
41
|
};
|
|
26
42
|
|
|
27
43
|
const ModelInfoSchema = z.object({
|
|
28
44
|
id: z.string(),
|
|
29
45
|
provider: z.string(),
|
|
30
46
|
description: z.string().optional(),
|
|
47
|
+
baseUrl: z.string().optional(),
|
|
48
|
+
apiKey: z.string().optional(),
|
|
31
49
|
...ModelParamsSchema,
|
|
32
50
|
});
|
|
33
51
|
|
package/src/connection.ts
CHANGED
package/src/debug.ts
ADDED
package/src/index.ts
CHANGED
|
@@ -45,20 +45,35 @@ const plugin = {
|
|
|
45
45
|
const baseUrl = `http://127.0.0.1:${config.proxyPort}/v1`;
|
|
46
46
|
const modelsByNode = groupModelsByNode(config);
|
|
47
47
|
|
|
48
|
-
// Patch openclaw config so auth resolution can find a dummy API key
|
|
49
|
-
//
|
|
50
|
-
|
|
51
|
-
|
|
48
|
+
// Patch openclaw config so auth resolution can find a dummy API key.
|
|
49
|
+
// resolveApiKeyForProvider checks cfg.models.providers, not the plugin registry.
|
|
50
|
+
// We must patch BOTH api.config (cfgAtStart) AND the runtimeConfigSnapshot
|
|
51
|
+
// (returned by loadConfig()) because activateSecretsRuntimeSnapshot clones the
|
|
52
|
+
// config before plugins load, so api.config and the snapshot are separate objects.
|
|
53
|
+
const patchProviders = (cfg: Record<string, unknown>) => {
|
|
54
|
+
const models = ((cfg).models ??= {}) as Record<string, unknown>;
|
|
55
|
+
const providers = (models.providers ??= {}) as Record<string, unknown>;
|
|
56
|
+
for (const [nodeId, nodeModels] of Object.entries(modelsByNode)) {
|
|
57
|
+
if (!providers[nodeId]) {
|
|
58
|
+
providers[nodeId] = { baseUrl, apiKey: "sk-clawmatrix-proxy", api: "openai-completions", models: nodeModels };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
};
|
|
52
62
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
63
|
+
patchProviders(api.config as Record<string, unknown>);
|
|
64
|
+
|
|
65
|
+
// Also patch the runtime config snapshot (loadConfig returns it by reference).
|
|
66
|
+
// api.runtime.config.loadConfig() returns runtimeConfigSnapshot directly.
|
|
67
|
+
try {
|
|
68
|
+
const snapshot = api.runtime.config.loadConfig();
|
|
69
|
+
if (snapshot && snapshot !== api.config) {
|
|
70
|
+
patchProviders(snapshot as Record<string, unknown>);
|
|
60
71
|
}
|
|
72
|
+
} catch {
|
|
73
|
+
// Best-effort; api.config patch is the fallback
|
|
74
|
+
}
|
|
61
75
|
|
|
76
|
+
for (const [nodeId, models] of Object.entries(modelsByNode)) {
|
|
62
77
|
api.registerProvider({
|
|
63
78
|
id: nodeId,
|
|
64
79
|
label: `ClawMatrix: ${nodeId}`,
|
|
@@ -176,6 +191,12 @@ const plugin = {
|
|
|
176
191
|
" - cluster_peers — inspect cluster topology",
|
|
177
192
|
"Use the node's description and tags to decide which node to target. " +
|
|
178
193
|
"For simple operations, prefer cluster_exec/read/write; for complex multi-step tasks, prefer cluster_handoff.",
|
|
194
|
+
"",
|
|
195
|
+
"IMPORTANT: Before calling any cluster tool or using a remote model, you MUST explicitly tell the user " +
|
|
196
|
+
"which remote node you are targeting and what you are about to do. For example:",
|
|
197
|
+
' "I\'m going to run this command on remote node «coder» ..."',
|
|
198
|
+
' "I\'m delegating this task to agent «reviewer» on node «server-b» ..."',
|
|
199
|
+
"This ensures the user always knows when operations leave the local node.",
|
|
179
200
|
);
|
|
180
201
|
|
|
181
202
|
return { prependContext: lines.join("\n") };
|
|
@@ -186,8 +207,8 @@ const plugin = {
|
|
|
186
207
|
},
|
|
187
208
|
};
|
|
188
209
|
|
|
189
|
-
function formatModel(m: { id: string; description?: string; api?: string; reasoning?: boolean; input?: string[]; cost?: { input: number; output: number; cacheRead: number; cacheWrite: number }; contextWindow?: number; maxTokens?: number }) {
|
|
190
|
-
|
|
210
|
+
function formatModel(m: { id: string; description?: string; api?: string; reasoning?: boolean; input?: string[]; cost?: { input: number; output: number; cacheRead: number; cacheWrite: number }; contextWindow?: number; maxTokens?: number; compat?: Record<string, unknown> }) {
|
|
211
|
+
const result: Record<string, unknown> = {
|
|
191
212
|
id: m.id,
|
|
192
213
|
name: m.description ?? m.id,
|
|
193
214
|
api: m.api ?? ("openai-completions" as const),
|
|
@@ -197,6 +218,8 @@ function formatModel(m: { id: string; description?: string; api?: string; reason
|
|
|
197
218
|
contextWindow: m.contextWindow ?? 128_000,
|
|
198
219
|
maxTokens: m.maxTokens ?? 4096,
|
|
199
220
|
};
|
|
221
|
+
if (m.compat) result.compat = m.compat;
|
|
222
|
+
return result;
|
|
200
223
|
}
|
|
201
224
|
|
|
202
225
|
function groupModelsByNode(config: ReturnType<typeof parseConfig>): Record<string, ReturnType<typeof formatModel>[]> {
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local tool execution for exec/read/write/edit.
|
|
3
|
+
*
|
|
4
|
+
* These tools cannot go through the Gateway HTTP `/tools/invoke` endpoint
|
|
5
|
+
* because it only exposes `createOpenClawTools()` (safe tools), not
|
|
6
|
+
* `createOpenClawCodingTools()` (which includes exec/read/write/edit).
|
|
7
|
+
*
|
|
8
|
+
* Instead, we execute them directly in the same Node.js process.
|
|
9
|
+
* - exec: simplified child_process.spawn (no sandbox/PTY/background)
|
|
10
|
+
* - read/write/edit: reuse @mariozechner/pi-coding-agent factories
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { spawn } from "node:child_process";
|
|
14
|
+
import {
|
|
15
|
+
createReadTool,
|
|
16
|
+
createWriteTool,
|
|
17
|
+
createEditTool,
|
|
18
|
+
} from "@mariozechner/pi-coding-agent";
|
|
19
|
+
|
|
20
|
+
// ── Types ──────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
interface ExecParams {
|
|
23
|
+
command: string;
|
|
24
|
+
workdir?: string;
|
|
25
|
+
env?: Record<string, string>;
|
|
26
|
+
timeout?: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type ToolResult = Record<string, unknown>;
|
|
30
|
+
|
|
31
|
+
// ── Constants ──────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
const LOCAL_TOOLS = new Set(["exec", "read", "write", "edit"]);
|
|
34
|
+
const DEFAULT_EXEC_TIMEOUT = 300; // seconds
|
|
35
|
+
const MAX_OUTPUT_BYTES = 512 * 1024; // 512KB
|
|
36
|
+
|
|
37
|
+
// ── Public API ─────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
export function isLocalTool(tool: string): boolean {
|
|
40
|
+
return LOCAL_TOOLS.has(tool);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function executeLocally(
|
|
44
|
+
tool: string,
|
|
45
|
+
params: Record<string, unknown>,
|
|
46
|
+
): Promise<ToolResult> {
|
|
47
|
+
switch (tool) {
|
|
48
|
+
case "exec":
|
|
49
|
+
return executeExec(params as unknown as ExecParams);
|
|
50
|
+
case "read":
|
|
51
|
+
return executePiTool("read", params);
|
|
52
|
+
case "write":
|
|
53
|
+
return executePiTool("write", params);
|
|
54
|
+
case "edit":
|
|
55
|
+
return executePiTool("edit", params);
|
|
56
|
+
default:
|
|
57
|
+
throw new Error(`Unknown local tool: ${tool}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ── exec: simplified child_process.spawn ───────────────────────────
|
|
62
|
+
|
|
63
|
+
async function executeExec(params: ExecParams): Promise<ToolResult> {
|
|
64
|
+
const { command, workdir, env, timeout } = params;
|
|
65
|
+
if (!command) throw new Error("exec: command is required");
|
|
66
|
+
|
|
67
|
+
const shell = process.env.SHELL || "/bin/bash";
|
|
68
|
+
const timeoutMs = (timeout ?? DEFAULT_EXEC_TIMEOUT) * 1000;
|
|
69
|
+
|
|
70
|
+
return new Promise<ToolResult>((resolve, reject) => {
|
|
71
|
+
const child = spawn(shell, ["-c", command], {
|
|
72
|
+
cwd: workdir || process.cwd(),
|
|
73
|
+
env: env ? { ...process.env, ...env } : process.env,
|
|
74
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
75
|
+
detached: true,
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
let stdout = "";
|
|
79
|
+
let stderr = "";
|
|
80
|
+
let killed = false;
|
|
81
|
+
|
|
82
|
+
child.stdout?.on("data", (chunk: Buffer) => {
|
|
83
|
+
stdout += chunk.toString();
|
|
84
|
+
if (stdout.length > MAX_OUTPUT_BYTES) {
|
|
85
|
+
stdout = stdout.slice(0, MAX_OUTPUT_BYTES);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
child.stderr?.on("data", (chunk: Buffer) => {
|
|
90
|
+
stderr += chunk.toString();
|
|
91
|
+
if (stderr.length > MAX_OUTPUT_BYTES) {
|
|
92
|
+
stderr = stderr.slice(0, MAX_OUTPUT_BYTES);
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const timer = setTimeout(() => {
|
|
97
|
+
killed = true;
|
|
98
|
+
// Kill the process group
|
|
99
|
+
try {
|
|
100
|
+
process.kill(-child.pid!, "SIGKILL");
|
|
101
|
+
} catch {
|
|
102
|
+
child.kill("SIGKILL");
|
|
103
|
+
}
|
|
104
|
+
}, timeoutMs);
|
|
105
|
+
|
|
106
|
+
child.on("close", (code, signal) => {
|
|
107
|
+
clearTimeout(timer);
|
|
108
|
+
const truncated =
|
|
109
|
+
stdout.length >= MAX_OUTPUT_BYTES || stderr.length >= MAX_OUTPUT_BYTES;
|
|
110
|
+
resolve({
|
|
111
|
+
exitCode: code ?? 1,
|
|
112
|
+
stdout,
|
|
113
|
+
stderr,
|
|
114
|
+
...(signal && { signal }),
|
|
115
|
+
...(killed && { timedOut: true }),
|
|
116
|
+
...(truncated && { truncated: true }),
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
child.on("error", (err) => {
|
|
121
|
+
clearTimeout(timer);
|
|
122
|
+
reject(new Error(`exec: failed to spawn: ${err.message}`));
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ── read/write/edit: reuse pi-coding-agent factories ───────────────
|
|
128
|
+
|
|
129
|
+
const piToolCache = new Map<string, { execute: Function }>();
|
|
130
|
+
|
|
131
|
+
function getPiTool(name: string): { execute: Function } {
|
|
132
|
+
let tool = piToolCache.get(name);
|
|
133
|
+
if (tool) return tool;
|
|
134
|
+
|
|
135
|
+
const cwd = process.cwd();
|
|
136
|
+
switch (name) {
|
|
137
|
+
case "read":
|
|
138
|
+
tool = createReadTool(cwd);
|
|
139
|
+
break;
|
|
140
|
+
case "write":
|
|
141
|
+
tool = createWriteTool(cwd);
|
|
142
|
+
break;
|
|
143
|
+
case "edit":
|
|
144
|
+
tool = createEditTool(cwd);
|
|
145
|
+
break;
|
|
146
|
+
default:
|
|
147
|
+
throw new Error(`Unknown pi tool: ${name}`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
piToolCache.set(name, tool);
|
|
151
|
+
return tool;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function executePiTool(
|
|
155
|
+
name: string,
|
|
156
|
+
params: Record<string, unknown>,
|
|
157
|
+
): Promise<ToolResult> {
|
|
158
|
+
const tool = getPiTool(name);
|
|
159
|
+
const toolCallId = crypto.randomUUID();
|
|
160
|
+
|
|
161
|
+
const result = (await tool.execute(toolCallId, params)) as {
|
|
162
|
+
content: Array<{ type: string; text?: string }>;
|
|
163
|
+
details: unknown;
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
// Flatten content array to a single text result
|
|
167
|
+
const text = result.content
|
|
168
|
+
.filter((c) => c.type === "text" && c.text)
|
|
169
|
+
.map((c) => c.text)
|
|
170
|
+
.join("\n");
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
content: text,
|
|
174
|
+
details: result.details,
|
|
175
|
+
};
|
|
176
|
+
}
|
package/src/model-proxy.ts
CHANGED
|
@@ -2,11 +2,13 @@ import { createServer, type Server } from "node:http";
|
|
|
2
2
|
import type { PeerManager } from "./peer-manager.ts";
|
|
3
3
|
import type { ClawMatrixConfig } from "./config.ts";
|
|
4
4
|
import type { GatewayInfo } from "./tool-proxy.ts";
|
|
5
|
+
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
|
5
6
|
import type {
|
|
6
7
|
ModelRequest,
|
|
7
8
|
ModelResponse,
|
|
8
9
|
ModelStreamChunk,
|
|
9
10
|
} from "./types.ts";
|
|
11
|
+
import { debug } from "./debug.ts";
|
|
10
12
|
|
|
11
13
|
const MODEL_TIMEOUT = 120_000; // 2 minutes
|
|
12
14
|
|
|
@@ -25,31 +27,81 @@ export class ModelProxy {
|
|
|
25
27
|
private pending = new Map<string, PendingModelReq>();
|
|
26
28
|
private httpServer: Server | null = null;
|
|
27
29
|
private gatewayInfo: GatewayInfo;
|
|
30
|
+
private openclawConfig: OpenClawConfig;
|
|
28
31
|
|
|
29
|
-
constructor(config: ClawMatrixConfig, peerManager: PeerManager, gatewayInfo: GatewayInfo) {
|
|
32
|
+
constructor(config: ClawMatrixConfig, peerManager: PeerManager, gatewayInfo: GatewayInfo, openclawConfig: OpenClawConfig) {
|
|
30
33
|
this.config = config;
|
|
31
34
|
this.peerManager = peerManager;
|
|
32
35
|
this.gatewayInfo = gatewayInfo;
|
|
36
|
+
this.openclawConfig = openclawConfig;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Resolve API endpoint for a model: explicit config > OpenClaw provider > gateway fallback */
|
|
40
|
+
private resolveModelEndpoint(model: { id: string; provider: string; baseUrl?: string; apiKey?: string }): { url: string; apiKey?: string; direct: boolean } {
|
|
41
|
+
// 1. Explicit baseUrl in ClawMatrix model config
|
|
42
|
+
if (model.baseUrl) {
|
|
43
|
+
return {
|
|
44
|
+
url: `${model.baseUrl.replace(/\/$/, "")}/chat/completions`,
|
|
45
|
+
apiKey: model.apiKey,
|
|
46
|
+
direct: true,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// 2. Read from OpenClaw's models.providers[provider]
|
|
51
|
+
const providers = (this.openclawConfig as Record<string, unknown>).models as
|
|
52
|
+
{ providers?: Record<string, { baseUrl?: string; apiKey?: string }> } | undefined;
|
|
53
|
+
const providerConfig = providers?.providers?.[model.provider];
|
|
54
|
+
if (providerConfig?.baseUrl) {
|
|
55
|
+
return {
|
|
56
|
+
url: `${providerConfig.baseUrl.replace(/\/$/, "")}/chat/completions`,
|
|
57
|
+
apiKey: typeof providerConfig.apiKey === "string" ? providerConfig.apiKey : undefined,
|
|
58
|
+
direct: true,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// 3. Fallback: OpenClaw gateway (goes through agent system — not recommended)
|
|
63
|
+
const { port } = this.gatewayInfo;
|
|
64
|
+
return {
|
|
65
|
+
url: `http://127.0.0.1:${port}/v1/chat/completions`,
|
|
66
|
+
apiKey: undefined,
|
|
67
|
+
direct: false,
|
|
68
|
+
};
|
|
33
69
|
}
|
|
34
70
|
|
|
35
71
|
/** Start the local HTTP proxy server for OpenAI-compatible requests. */
|
|
36
72
|
start() {
|
|
37
73
|
this.httpServer = createServer(async (req, res) => {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
74
|
+
try {
|
|
75
|
+
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
|
76
|
+
|
|
77
|
+
const p = url.pathname.replace(/^\/v1/, "");
|
|
78
|
+
debug("proxy", `${req.method} ${url.pathname} → ${p}`);
|
|
79
|
+
|
|
80
|
+
if (p === "/chat/completions" && req.method === "POST") {
|
|
81
|
+
const body = await this.readBody(req);
|
|
82
|
+
const response = await this.handleChatCompletion(body);
|
|
83
|
+
debug("proxy", `response status=${response.status}`);
|
|
84
|
+
this.sendResponse(res, response);
|
|
85
|
+
} else if (p === "/models" && req.method === "GET") {
|
|
86
|
+
const response = this.handleListModels();
|
|
87
|
+
this.sendResponse(res, response);
|
|
88
|
+
} else {
|
|
89
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
90
|
+
res.end(JSON.stringify({ error: { message: `No handler for ${req.method} ${url.pathname}` } }));
|
|
91
|
+
}
|
|
92
|
+
} catch {
|
|
93
|
+
if (!res.headersSent) {
|
|
94
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
95
|
+
}
|
|
96
|
+
res.end(JSON.stringify({ error: { message: "Internal proxy error" } }));
|
|
50
97
|
}
|
|
51
98
|
});
|
|
52
99
|
|
|
100
|
+
this.httpServer.on("error", (err) => {
|
|
101
|
+
// Log but don't crash — port conflict or other server error
|
|
102
|
+
console.error(`[clawmatrix] Model proxy server error: ${err.message}`);
|
|
103
|
+
});
|
|
104
|
+
|
|
53
105
|
this.httpServer.listen(this.config.proxyPort, "127.0.0.1");
|
|
54
106
|
}
|
|
55
107
|
|
|
@@ -84,12 +136,16 @@ export class ModelProxy {
|
|
|
84
136
|
const pump = (): void => {
|
|
85
137
|
reader.read().then(({ done, value }) => {
|
|
86
138
|
if (done) {
|
|
139
|
+
reader.releaseLock();
|
|
87
140
|
res.end();
|
|
88
141
|
return;
|
|
89
142
|
}
|
|
90
143
|
res.write(value);
|
|
91
144
|
pump();
|
|
92
|
-
}).catch(() =>
|
|
145
|
+
}).catch(() => {
|
|
146
|
+
reader.releaseLock();
|
|
147
|
+
res.end();
|
|
148
|
+
});
|
|
93
149
|
};
|
|
94
150
|
pump();
|
|
95
151
|
}
|
|
@@ -115,17 +171,32 @@ export class ModelProxy {
|
|
|
115
171
|
};
|
|
116
172
|
}
|
|
117
173
|
|
|
118
|
-
const
|
|
119
|
-
//
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
174
|
+
const rawModelId = body.model;
|
|
175
|
+
// Parse "nodeId/model" format: first segment is nodeId, rest is model ID.
|
|
176
|
+
// OpenClaw sends "providerId/modelId" where providerId = nodeId, so this
|
|
177
|
+
// naturally handles both OpenClaw calls and direct curl calls.
|
|
178
|
+
// If no "/" present, treat entire string as model ID and auto-resolve.
|
|
179
|
+
let nodeId: string | undefined;
|
|
180
|
+
let modelId: string;
|
|
181
|
+
const slashIdx = rawModelId.indexOf("/");
|
|
182
|
+
if (slashIdx > 0) {
|
|
183
|
+
nodeId = rawModelId.slice(0, slashIdx);
|
|
184
|
+
modelId = rawModelId.slice(slashIdx + 1);
|
|
185
|
+
} else {
|
|
186
|
+
modelId = rawModelId;
|
|
187
|
+
}
|
|
188
|
+
debug("proxy", `model raw="${rawModelId}" nodeId=${nodeId ?? "auto"} modelId="${modelId}" stream=${body.stream ?? false}`);
|
|
189
|
+
const proxyModel = this.config.proxyModels.find((m) => m.id === modelId && (!nodeId || m.nodeId === nodeId))
|
|
190
|
+
?? this.config.proxyModels.find((m) => m.id === modelId);
|
|
191
|
+
const route = nodeId
|
|
192
|
+
? this.peerManager.router.getRoute(nodeId)
|
|
123
193
|
: this.peerManager.router.resolveModel(modelId);
|
|
194
|
+
debug("proxy", `proxyModel=${proxyModel?.id ?? "none"} route=${route?.nodeId ?? "none"} reachable=${route ? this.peerManager.canReach(route.nodeId) : false}`);
|
|
124
195
|
if (!route) {
|
|
125
196
|
return {
|
|
126
197
|
status: 404,
|
|
127
198
|
headers: { "Content-Type": "application/json" },
|
|
128
|
-
body: JSON.stringify({ error: { message: `Model "${modelId}" not found in cluster` } }),
|
|
199
|
+
body: JSON.stringify({ error: { message: `Model "${modelId}" not found in cluster (proxyModels: [${this.config.proxyModels.map(m => m.id).join(", ")}])` } }),
|
|
129
200
|
};
|
|
130
201
|
}
|
|
131
202
|
|
|
@@ -158,6 +229,15 @@ export class ModelProxy {
|
|
|
158
229
|
},
|
|
159
230
|
};
|
|
160
231
|
|
|
232
|
+
// Pre-check reachability before starting a stream (avoids silent empty response)
|
|
233
|
+
if (!this.peerManager.canReach(route.nodeId)) {
|
|
234
|
+
return {
|
|
235
|
+
status: 502,
|
|
236
|
+
headers: { "Content-Type": "application/json" },
|
|
237
|
+
body: JSON.stringify({ error: { message: `Cannot reach model node "${route.nodeId}"` } }),
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
161
241
|
if (stream) {
|
|
162
242
|
return this.handleStreamRequest(requestId, route.nodeId, frame);
|
|
163
243
|
} else {
|
|
@@ -178,8 +258,13 @@ export class ModelProxy {
|
|
|
178
258
|
this.pending.delete(requestId);
|
|
179
259
|
this.peerManager.router.markFailed(requestId);
|
|
180
260
|
try {
|
|
261
|
+
const errorChunk = {
|
|
262
|
+
id: `chatcmpl-${requestId}`,
|
|
263
|
+
object: "chat.completion.chunk",
|
|
264
|
+
choices: [{ index: 0, delta: { content: "\n\n[ClawMatrix] Error: model request timed out" }, finish_reason: "stop" }],
|
|
265
|
+
};
|
|
181
266
|
controller.enqueue(
|
|
182
|
-
encoder.encode(`data: ${JSON.stringify(
|
|
267
|
+
encoder.encode(`data: ${JSON.stringify(errorChunk)}\n\n`),
|
|
183
268
|
);
|
|
184
269
|
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
|
|
185
270
|
controller.close();
|
|
@@ -201,10 +286,13 @@ export class ModelProxy {
|
|
|
201
286
|
if (!sent) {
|
|
202
287
|
this.pending.delete(requestId);
|
|
203
288
|
clearTimeout(timer);
|
|
289
|
+
const errChunk = {
|
|
290
|
+
id: `chatcmpl-${requestId}`,
|
|
291
|
+
object: "chat.completion.chunk",
|
|
292
|
+
choices: [{ index: 0, delta: { content: `[ClawMatrix] Cannot reach model node "${targetNodeId}"` }, finish_reason: "stop" }],
|
|
293
|
+
};
|
|
204
294
|
controller.enqueue(
|
|
205
|
-
encoder.encode(
|
|
206
|
-
`data: ${JSON.stringify({ error: "Cannot reach model node" })}\n\n`,
|
|
207
|
-
),
|
|
295
|
+
encoder.encode(`data: ${JSON.stringify(errChunk)}\n\n`),
|
|
208
296
|
);
|
|
209
297
|
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
|
|
210
298
|
controller.close();
|
|
@@ -317,7 +405,31 @@ export class ModelProxy {
|
|
|
317
405
|
handleModelResponse(frame: ModelResponse) {
|
|
318
406
|
if (this.peerManager.router.isFailed(frame.id)) return;
|
|
319
407
|
const pending = this.pending.get(frame.id);
|
|
320
|
-
if (!pending
|
|
408
|
+
if (!pending) return;
|
|
409
|
+
|
|
410
|
+
// For stream requests, handle error responses (the remote node couldn't
|
|
411
|
+
// process the request and sent model_res instead of model_stream).
|
|
412
|
+
if (pending.stream) {
|
|
413
|
+
if (!frame.payload.success && pending.controller && pending.encoder) {
|
|
414
|
+
clearTimeout(pending.timer);
|
|
415
|
+
this.pending.delete(frame.id);
|
|
416
|
+
try {
|
|
417
|
+
const errChunk = {
|
|
418
|
+
id: `chatcmpl-${frame.id}`,
|
|
419
|
+
object: "chat.completion.chunk",
|
|
420
|
+
choices: [{ index: 0, delta: { content: `[ClawMatrix] Remote error: ${frame.payload.error}` }, finish_reason: "stop" }],
|
|
421
|
+
};
|
|
422
|
+
pending.controller.enqueue(
|
|
423
|
+
pending.encoder.encode(`data: ${JSON.stringify(errChunk)}\n\n`),
|
|
424
|
+
);
|
|
425
|
+
pending.controller.enqueue(pending.encoder.encode("data: [DONE]\n\n"));
|
|
426
|
+
pending.controller.close();
|
|
427
|
+
} catch {
|
|
428
|
+
// controller may already be closed
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
321
433
|
|
|
322
434
|
clearTimeout(pending.timer);
|
|
323
435
|
this.pending.delete(frame.id);
|
|
@@ -325,27 +437,28 @@ export class ModelProxy {
|
|
|
325
437
|
}
|
|
326
438
|
|
|
327
439
|
handleModelStream(frame: ModelStreamChunk) {
|
|
440
|
+
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)}`);
|
|
328
441
|
if (this.peerManager.router.isFailed(frame.id)) return;
|
|
329
442
|
const pending = this.pending.get(frame.id);
|
|
330
443
|
if (!pending?.stream || !pending.controller || !pending.encoder) return;
|
|
331
444
|
|
|
332
445
|
try {
|
|
333
446
|
if (frame.payload.done) {
|
|
447
|
+
const finalChunk: Record<string, unknown> = {
|
|
448
|
+
id: `chatcmpl-${frame.id}`,
|
|
449
|
+
object: "chat.completion.chunk",
|
|
450
|
+
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
|
|
451
|
+
};
|
|
334
452
|
if (frame.payload.usage) {
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
usage: {
|
|
340
|
-
prompt_tokens: frame.payload.usage.inputTokens,
|
|
341
|
-
completion_tokens: frame.payload.usage.outputTokens,
|
|
342
|
-
total_tokens: frame.payload.usage.inputTokens + frame.payload.usage.outputTokens,
|
|
343
|
-
},
|
|
453
|
+
finalChunk.usage = {
|
|
454
|
+
prompt_tokens: frame.payload.usage.inputTokens,
|
|
455
|
+
completion_tokens: frame.payload.usage.outputTokens,
|
|
456
|
+
total_tokens: frame.payload.usage.inputTokens + frame.payload.usage.outputTokens,
|
|
344
457
|
};
|
|
345
|
-
pending.controller.enqueue(
|
|
346
|
-
pending.encoder.encode(`data: ${JSON.stringify(usageChunk)}\n\n`),
|
|
347
|
-
);
|
|
348
458
|
}
|
|
459
|
+
pending.controller.enqueue(
|
|
460
|
+
pending.encoder.encode(`data: ${JSON.stringify(finalChunk)}\n\n`),
|
|
461
|
+
);
|
|
349
462
|
pending.controller.enqueue(pending.encoder.encode("data: [DONE]\n\n"));
|
|
350
463
|
pending.controller.close();
|
|
351
464
|
clearTimeout(pending.timer);
|
|
@@ -372,9 +485,10 @@ export class ModelProxy {
|
|
|
372
485
|
}
|
|
373
486
|
}
|
|
374
487
|
|
|
375
|
-
/** Handle model_req locally:
|
|
488
|
+
/** Handle model_req locally: call the model API directly or fall back to OpenClaw gateway. */
|
|
376
489
|
async handleModelRequest(frame: ModelRequest): Promise<void> {
|
|
377
490
|
const { id, from, payload } = frame;
|
|
491
|
+
debug("model_req", `handling model="${payload.model}" from=${from} stream=${payload.stream}`);
|
|
378
492
|
|
|
379
493
|
const model = this.config.models.find((m) => m.id === payload.model);
|
|
380
494
|
if (!model) {
|
|
@@ -390,70 +504,109 @@ export class ModelProxy {
|
|
|
390
504
|
}
|
|
391
505
|
|
|
392
506
|
try {
|
|
393
|
-
const
|
|
507
|
+
const endpoint = this.resolveModelEndpoint(model);
|
|
394
508
|
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
395
|
-
if (authHeader) headers["Authorization"] = authHeader;
|
|
396
509
|
|
|
397
|
-
|
|
510
|
+
if (endpoint.direct) {
|
|
511
|
+
if (endpoint.apiKey) headers["Authorization"] = `Bearer ${endpoint.apiKey}`;
|
|
512
|
+
debug("model_req", `direct API call to ${endpoint.url}`);
|
|
513
|
+
} else {
|
|
514
|
+
const { authHeader } = this.gatewayInfo;
|
|
515
|
+
if (authHeader) headers["Authorization"] = authHeader;
|
|
516
|
+
debug("model_req", `gateway fallback to ${endpoint.url} (not recommended)`);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
const response = await fetch(endpoint.url, {
|
|
398
520
|
method: "POST",
|
|
399
521
|
headers,
|
|
400
522
|
body: JSON.stringify({
|
|
401
|
-
model: `${model.provider}/${model.id}`,
|
|
523
|
+
model: endpoint.direct ? model.id : `${model.provider}/${model.id}`,
|
|
402
524
|
messages: payload.messages,
|
|
403
525
|
temperature: payload.temperature,
|
|
404
526
|
max_tokens: payload.maxTokens,
|
|
405
527
|
stream: payload.stream,
|
|
528
|
+
...(payload.stream ? { stream_options: { include_usage: true } } : {}),
|
|
406
529
|
}),
|
|
407
530
|
});
|
|
408
531
|
|
|
532
|
+
if (!response.ok) {
|
|
533
|
+
const errBody = await response.text();
|
|
534
|
+
throw new Error(`Upstream ${response.status}: ${errBody.slice(0, 200)}`);
|
|
535
|
+
}
|
|
536
|
+
|
|
409
537
|
if (payload.stream) {
|
|
410
538
|
const reader = response.body?.getReader();
|
|
411
539
|
if (!reader) throw new Error("No response body");
|
|
412
540
|
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
const data = line.slice(6).trim();
|
|
427
|
-
if (data === "[DONE]") {
|
|
428
|
-
this.peerManager.sendTo(from, {
|
|
429
|
-
type: "model_stream",
|
|
430
|
-
id,
|
|
431
|
-
from: this.config.nodeId,
|
|
432
|
-
to: from,
|
|
433
|
-
timestamp: Date.now(),
|
|
434
|
-
payload: { delta: "", done: true },
|
|
435
|
-
} satisfies ModelStreamChunk);
|
|
436
|
-
break;
|
|
437
|
-
}
|
|
541
|
+
try {
|
|
542
|
+
const decoder = new TextDecoder();
|
|
543
|
+
let buffer = "";
|
|
544
|
+
let lastUsage: { inputTokens: number; outputTokens: number } | undefined;
|
|
545
|
+
let streamDone = false;
|
|
546
|
+
|
|
547
|
+
while (!streamDone) {
|
|
548
|
+
const { done, value } = await reader.read();
|
|
549
|
+
if (done) break;
|
|
550
|
+
|
|
551
|
+
buffer += decoder.decode(value, { stream: true });
|
|
552
|
+
const lines = buffer.split("\n");
|
|
553
|
+
buffer = lines.pop()!;
|
|
438
554
|
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
const
|
|
442
|
-
|
|
443
|
-
if (delta) {
|
|
555
|
+
for (const line of lines) {
|
|
556
|
+
if (!line.startsWith("data: ")) continue;
|
|
557
|
+
const data = line.slice(6).trim();
|
|
558
|
+
if (data === "[DONE]") {
|
|
444
559
|
this.peerManager.sendTo(from, {
|
|
445
560
|
type: "model_stream",
|
|
446
561
|
id,
|
|
447
562
|
from: this.config.nodeId,
|
|
448
563
|
to: from,
|
|
449
564
|
timestamp: Date.now(),
|
|
450
|
-
payload: { delta, done:
|
|
565
|
+
payload: { delta: "", done: true, usage: lastUsage },
|
|
451
566
|
} satisfies ModelStreamChunk);
|
|
567
|
+
streamDone = true;
|
|
568
|
+
break;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
try {
|
|
572
|
+
const parsed = JSON.parse(data);
|
|
573
|
+
if (parsed.usage) {
|
|
574
|
+
lastUsage = {
|
|
575
|
+
inputTokens: parsed.usage.prompt_tokens,
|
|
576
|
+
outputTokens: parsed.usage.completion_tokens,
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
const d = parsed.choices?.[0]?.delta;
|
|
580
|
+
const delta = d?.content || d?.reasoning_content || "";
|
|
581
|
+
if (delta) {
|
|
582
|
+
this.peerManager.sendTo(from, {
|
|
583
|
+
type: "model_stream",
|
|
584
|
+
id,
|
|
585
|
+
from: this.config.nodeId,
|
|
586
|
+
to: from,
|
|
587
|
+
timestamp: Date.now(),
|
|
588
|
+
payload: { delta, done: false },
|
|
589
|
+
} satisfies ModelStreamChunk);
|
|
590
|
+
}
|
|
591
|
+
} catch {
|
|
592
|
+
// skip malformed chunks
|
|
452
593
|
}
|
|
453
|
-
} catch {
|
|
454
|
-
// skip malformed chunks
|
|
455
594
|
}
|
|
456
595
|
}
|
|
596
|
+
// If the upstream closed without sending [DONE], send a completion
|
|
597
|
+
// frame so the requesting side doesn't hang until MODEL_TIMEOUT.
|
|
598
|
+
if (!streamDone) {
|
|
599
|
+
this.peerManager.sendTo(from, {
|
|
600
|
+
type: "model_stream",
|
|
601
|
+
id,
|
|
602
|
+
from: this.config.nodeId,
|
|
603
|
+
to: from,
|
|
604
|
+
timestamp: Date.now(),
|
|
605
|
+
payload: { delta: "", done: true, usage: lastUsage },
|
|
606
|
+
} satisfies ModelStreamChunk);
|
|
607
|
+
}
|
|
608
|
+
} finally {
|
|
609
|
+
reader.releaseLock();
|
|
457
610
|
}
|
|
458
611
|
} else {
|
|
459
612
|
const result = (await response.json()) as {
|
package/src/peer-manager.ts
CHANGED
|
@@ -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
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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 ─────────────────────────────────────
|
|
@@ -211,7 +224,9 @@ export class Router {
|
|
|
211
224
|
tryRelay(frame: ClusterFrame): boolean {
|
|
212
225
|
if (!frame.to || frame.to === this.nodeId) return false;
|
|
213
226
|
|
|
214
|
-
const
|
|
227
|
+
const rawTtl = frame.ttl ?? DEFAULT_TTL;
|
|
228
|
+
if (typeof rawTtl !== "number" || !Number.isFinite(rawTtl) || rawTtl < 1) return false;
|
|
229
|
+
const ttl = rawTtl - 1;
|
|
215
230
|
if (ttl <= 0) return false;
|
|
216
231
|
|
|
217
232
|
const relayed = this.sendTo(frame.to, { ...frame, ttl });
|
|
@@ -238,8 +253,8 @@ export class Router {
|
|
|
238
253
|
return this.seenFrames.has(`failed:${requestId}`);
|
|
239
254
|
}
|
|
240
255
|
|
|
241
|
-
private pruneSeenFrames() {
|
|
242
|
-
if (this.seenFrames.size <= MAX_SEEN_FRAMES) return;
|
|
256
|
+
private pruneSeenFrames(force = false) {
|
|
257
|
+
if (!force && this.seenFrames.size <= MAX_SEEN_FRAMES) return;
|
|
243
258
|
const now = Date.now();
|
|
244
259
|
for (const [id, ts] of this.seenFrames) {
|
|
245
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 =
|
|
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
|
@@ -170,10 +170,26 @@ export interface AgentInfo {
|
|
|
170
170
|
tags: string[];
|
|
171
171
|
}
|
|
172
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
|
+
|
|
173
188
|
export interface ModelInfo {
|
|
174
189
|
id: string;
|
|
175
190
|
provider: string;
|
|
176
191
|
description?: string;
|
|
192
|
+
compat?: ModelCompatInfo;
|
|
177
193
|
}
|
|
178
194
|
|
|
179
195
|
export interface PeerInfo {
|