clawmatrix 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -0
- package/llms.txt +191 -0
- package/openclaw.plugin.json +76 -0
- package/package.json +43 -0
- package/src/auth.ts +38 -0
- package/src/cli.ts +84 -0
- package/src/cluster-service.ts +160 -0
- package/src/config.ts +50 -0
- package/src/connection.ts +261 -0
- package/src/handoff.ts +201 -0
- package/src/index.ts +119 -0
- package/src/model-proxy.ts +441 -0
- package/src/peer-manager.ts +333 -0
- package/src/router.ts +275 -0
- package/src/tool-proxy.ts +324 -0
- package/src/tools/cluster-exec.ts +66 -0
- package/src/tools/cluster-handoff.ts +82 -0
- package/src/tools/cluster-ls.ts +51 -0
- package/src/tools/cluster-peers.ts +55 -0
- package/src/tools/cluster-read.ts +48 -0
- package/src/tools/cluster-send.ts +79 -0
- package/src/tools/cluster-write.ts +61 -0
- package/src/types.ts +197 -0
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
import type { PeerManager } from "./peer-manager.ts";
|
|
2
|
+
import type { ClawMatrixConfig } from "./config.ts";
|
|
3
|
+
import type {
|
|
4
|
+
ModelRequest,
|
|
5
|
+
ModelResponse,
|
|
6
|
+
ModelStreamChunk,
|
|
7
|
+
} from "./types.ts";
|
|
8
|
+
|
|
9
|
+
const MODEL_TIMEOUT = 120_000; // 2 minutes
|
|
10
|
+
|
|
11
|
+
interface PendingModelReq {
|
|
12
|
+
resolve: (value: unknown) => void;
|
|
13
|
+
reject: (error: Error) => void;
|
|
14
|
+
timer: ReturnType<typeof setTimeout>;
|
|
15
|
+
stream: boolean;
|
|
16
|
+
controller?: ReadableStreamDefaultController;
|
|
17
|
+
encoder?: TextEncoder;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class ModelProxy {
|
|
21
|
+
private config: ClawMatrixConfig;
|
|
22
|
+
private peerManager: PeerManager;
|
|
23
|
+
private pending = new Map<string, PendingModelReq>();
|
|
24
|
+
private httpServer: ReturnType<typeof Bun.serve> | null = null;
|
|
25
|
+
|
|
26
|
+
constructor(config: ClawMatrixConfig, peerManager: PeerManager) {
|
|
27
|
+
this.config = config;
|
|
28
|
+
this.peerManager = peerManager;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Start the local HTTP proxy server for OpenAI-compatible requests. */
|
|
32
|
+
start() {
|
|
33
|
+
this.httpServer = Bun.serve({
|
|
34
|
+
port: this.config.proxyPort,
|
|
35
|
+
hostname: "127.0.0.1",
|
|
36
|
+
routes: {
|
|
37
|
+
"/v1/chat/completions": {
|
|
38
|
+
POST: async (req) => this.handleChatCompletion(req),
|
|
39
|
+
},
|
|
40
|
+
"/v1/models": {
|
|
41
|
+
GET: () => this.handleListModels(),
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
fetch() {
|
|
45
|
+
return new Response("Not Found", { status: 404 });
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
stop() {
|
|
51
|
+
if (this.httpServer) {
|
|
52
|
+
this.httpServer.stop();
|
|
53
|
+
this.httpServer = null;
|
|
54
|
+
}
|
|
55
|
+
for (const [, pending] of this.pending) {
|
|
56
|
+
clearTimeout(pending.timer);
|
|
57
|
+
pending.reject(new Error("Shutting down"));
|
|
58
|
+
}
|
|
59
|
+
this.pending.clear();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ── HTTP handlers ──────────────────────────────────────────────
|
|
63
|
+
private async handleChatCompletion(req: Request): Promise<Response> {
|
|
64
|
+
let body: {
|
|
65
|
+
model: string;
|
|
66
|
+
messages: unknown[];
|
|
67
|
+
stream?: boolean;
|
|
68
|
+
temperature?: number;
|
|
69
|
+
max_tokens?: number;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
body = (await req.json()) as typeof body;
|
|
74
|
+
} catch {
|
|
75
|
+
return new Response(JSON.stringify({ error: "Invalid JSON" }), {
|
|
76
|
+
status: 400,
|
|
77
|
+
headers: { "Content-Type": "application/json" },
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const modelId = body.model;
|
|
82
|
+
const route = this.peerManager.router.resolveModel(modelId);
|
|
83
|
+
if (!route) {
|
|
84
|
+
return new Response(
|
|
85
|
+
JSON.stringify({ error: { message: `Model "${modelId}" not found in cluster` } }),
|
|
86
|
+
{ status: 404, headers: { "Content-Type": "application/json" } },
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const stream = body.stream ?? false;
|
|
91
|
+
const requestId = crypto.randomUUID();
|
|
92
|
+
|
|
93
|
+
const frame: ModelRequest = {
|
|
94
|
+
type: "model_req",
|
|
95
|
+
id: requestId,
|
|
96
|
+
from: this.config.nodeId,
|
|
97
|
+
to: route.nodeId,
|
|
98
|
+
timestamp: Date.now(),
|
|
99
|
+
payload: {
|
|
100
|
+
model: modelId,
|
|
101
|
+
messages: body.messages,
|
|
102
|
+
temperature: body.temperature,
|
|
103
|
+
maxTokens: body.max_tokens,
|
|
104
|
+
stream,
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
if (stream) {
|
|
109
|
+
return this.handleStreamRequest(requestId, route.nodeId, frame);
|
|
110
|
+
} else {
|
|
111
|
+
return this.handleNonStreamRequest(requestId, route.nodeId, frame);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
private handleStreamRequest(
|
|
116
|
+
requestId: string,
|
|
117
|
+
targetNodeId: string,
|
|
118
|
+
frame: ModelRequest,
|
|
119
|
+
): Response {
|
|
120
|
+
const encoder = new TextEncoder();
|
|
121
|
+
|
|
122
|
+
const readable = new ReadableStream({
|
|
123
|
+
start: (controller) => {
|
|
124
|
+
const timer = setTimeout(() => {
|
|
125
|
+
this.pending.delete(requestId);
|
|
126
|
+
this.peerManager.router.markFailed(requestId);
|
|
127
|
+
try {
|
|
128
|
+
controller.enqueue(
|
|
129
|
+
encoder.encode(`data: ${JSON.stringify({ error: "timeout" })}\n\n`),
|
|
130
|
+
);
|
|
131
|
+
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
|
|
132
|
+
controller.close();
|
|
133
|
+
} catch {
|
|
134
|
+
// controller may already be closed
|
|
135
|
+
}
|
|
136
|
+
}, MODEL_TIMEOUT);
|
|
137
|
+
|
|
138
|
+
this.pending.set(requestId, {
|
|
139
|
+
resolve: () => {},
|
|
140
|
+
reject: () => {},
|
|
141
|
+
timer,
|
|
142
|
+
stream: true,
|
|
143
|
+
controller,
|
|
144
|
+
encoder,
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
const sent = this.peerManager.sendTo(targetNodeId, frame);
|
|
148
|
+
if (!sent) {
|
|
149
|
+
this.pending.delete(requestId);
|
|
150
|
+
clearTimeout(timer);
|
|
151
|
+
controller.enqueue(
|
|
152
|
+
encoder.encode(
|
|
153
|
+
`data: ${JSON.stringify({ error: "Cannot reach model node" })}\n\n`,
|
|
154
|
+
),
|
|
155
|
+
);
|
|
156
|
+
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
|
|
157
|
+
controller.close();
|
|
158
|
+
}
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
return new Response(readable, {
|
|
163
|
+
headers: {
|
|
164
|
+
"Content-Type": "text/event-stream",
|
|
165
|
+
"Cache-Control": "no-cache",
|
|
166
|
+
Connection: "keep-alive",
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
private async handleNonStreamRequest(
|
|
172
|
+
requestId: string,
|
|
173
|
+
targetNodeId: string,
|
|
174
|
+
frame: ModelRequest,
|
|
175
|
+
): Promise<Response> {
|
|
176
|
+
try {
|
|
177
|
+
const result = await new Promise<ModelResponse["payload"]>(
|
|
178
|
+
(resolve, reject) => {
|
|
179
|
+
const timer = setTimeout(() => {
|
|
180
|
+
this.pending.delete(requestId);
|
|
181
|
+
this.peerManager.router.markFailed(requestId);
|
|
182
|
+
reject(new Error("Model request timed out"));
|
|
183
|
+
}, MODEL_TIMEOUT);
|
|
184
|
+
|
|
185
|
+
this.pending.set(requestId, {
|
|
186
|
+
resolve: resolve as (v: unknown) => void,
|
|
187
|
+
reject,
|
|
188
|
+
timer,
|
|
189
|
+
stream: false,
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
const sent = this.peerManager.sendTo(targetNodeId, frame);
|
|
193
|
+
if (!sent) {
|
|
194
|
+
this.pending.delete(requestId);
|
|
195
|
+
clearTimeout(timer);
|
|
196
|
+
reject(new Error("Cannot reach model node"));
|
|
197
|
+
}
|
|
198
|
+
},
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
if (!result.success) {
|
|
202
|
+
return new Response(
|
|
203
|
+
JSON.stringify({ error: { message: result.error } }),
|
|
204
|
+
{ status: 502, headers: { "Content-Type": "application/json" } },
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// OpenAI-compatible response
|
|
209
|
+
return new Response(
|
|
210
|
+
JSON.stringify({
|
|
211
|
+
id: `chatcmpl-${requestId}`,
|
|
212
|
+
object: "chat.completion",
|
|
213
|
+
created: Math.floor(Date.now() / 1000),
|
|
214
|
+
model: frame.payload.model,
|
|
215
|
+
choices: [
|
|
216
|
+
{
|
|
217
|
+
index: 0,
|
|
218
|
+
message: { role: "assistant", content: result.content },
|
|
219
|
+
finish_reason: "stop",
|
|
220
|
+
},
|
|
221
|
+
],
|
|
222
|
+
usage: result.usage
|
|
223
|
+
? {
|
|
224
|
+
prompt_tokens: result.usage.inputTokens,
|
|
225
|
+
completion_tokens: result.usage.outputTokens,
|
|
226
|
+
total_tokens: result.usage.inputTokens + result.usage.outputTokens,
|
|
227
|
+
}
|
|
228
|
+
: undefined,
|
|
229
|
+
}),
|
|
230
|
+
{ headers: { "Content-Type": "application/json" } },
|
|
231
|
+
);
|
|
232
|
+
} catch (err) {
|
|
233
|
+
return new Response(
|
|
234
|
+
JSON.stringify({ error: { message: err instanceof Error ? err.message : String(err) } }),
|
|
235
|
+
{ status: 502, headers: { "Content-Type": "application/json" } },
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
private handleListModels(): Response {
|
|
241
|
+
const models = this.peerManager.router
|
|
242
|
+
.getAllPeers()
|
|
243
|
+
.flatMap((p) =>
|
|
244
|
+
p.models.map((m) => ({
|
|
245
|
+
id: m.id,
|
|
246
|
+
object: "model",
|
|
247
|
+
created: 0,
|
|
248
|
+
owned_by: m.provider,
|
|
249
|
+
})),
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
return new Response(
|
|
253
|
+
JSON.stringify({ object: "list", data: models }),
|
|
254
|
+
{ headers: { "Content-Type": "application/json" } },
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ── Incoming frame handlers ────────────────────────────────────
|
|
259
|
+
handleModelResponse(frame: ModelResponse) {
|
|
260
|
+
if (this.peerManager.router.isFailed(frame.id)) return;
|
|
261
|
+
const pending = this.pending.get(frame.id);
|
|
262
|
+
if (!pending || pending.stream) return;
|
|
263
|
+
|
|
264
|
+
clearTimeout(pending.timer);
|
|
265
|
+
this.pending.delete(frame.id);
|
|
266
|
+
pending.resolve(frame.payload);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
handleModelStream(frame: ModelStreamChunk) {
|
|
270
|
+
if (this.peerManager.router.isFailed(frame.id)) return;
|
|
271
|
+
const pending = this.pending.get(frame.id);
|
|
272
|
+
if (!pending?.stream || !pending.controller || !pending.encoder) return;
|
|
273
|
+
|
|
274
|
+
try {
|
|
275
|
+
if (frame.payload.done) {
|
|
276
|
+
// Send final chunk with usage if available
|
|
277
|
+
if (frame.payload.usage) {
|
|
278
|
+
const usageChunk = {
|
|
279
|
+
id: `chatcmpl-${frame.id}`,
|
|
280
|
+
object: "chat.completion.chunk",
|
|
281
|
+
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
|
|
282
|
+
usage: {
|
|
283
|
+
prompt_tokens: frame.payload.usage.inputTokens,
|
|
284
|
+
completion_tokens: frame.payload.usage.outputTokens,
|
|
285
|
+
total_tokens: frame.payload.usage.inputTokens + frame.payload.usage.outputTokens,
|
|
286
|
+
},
|
|
287
|
+
};
|
|
288
|
+
pending.controller.enqueue(
|
|
289
|
+
pending.encoder.encode(`data: ${JSON.stringify(usageChunk)}\n\n`),
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
pending.controller.enqueue(pending.encoder.encode("data: [DONE]\n\n"));
|
|
293
|
+
pending.controller.close();
|
|
294
|
+
clearTimeout(pending.timer);
|
|
295
|
+
this.pending.delete(frame.id);
|
|
296
|
+
} else {
|
|
297
|
+
const chunk = {
|
|
298
|
+
id: `chatcmpl-${frame.id}`,
|
|
299
|
+
object: "chat.completion.chunk",
|
|
300
|
+
choices: [
|
|
301
|
+
{
|
|
302
|
+
index: 0,
|
|
303
|
+
delta: { content: frame.payload.delta },
|
|
304
|
+
finish_reason: null,
|
|
305
|
+
},
|
|
306
|
+
],
|
|
307
|
+
};
|
|
308
|
+
pending.controller.enqueue(
|
|
309
|
+
pending.encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`),
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
} catch {
|
|
313
|
+
// controller may be closed
|
|
314
|
+
clearTimeout(pending.timer);
|
|
315
|
+
this.pending.delete(frame.id);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Handle model_req locally: forward to OpenClaw's configured model provider. */
|
|
320
|
+
async handleModelRequest(frame: ModelRequest): Promise<void> {
|
|
321
|
+
const { id, from, payload } = frame;
|
|
322
|
+
|
|
323
|
+
// Find the model in our local config
|
|
324
|
+
const model = this.config.models.find((m) => m.id === payload.model);
|
|
325
|
+
if (!model) {
|
|
326
|
+
this.peerManager.sendTo(from, {
|
|
327
|
+
type: "model_res",
|
|
328
|
+
id,
|
|
329
|
+
from: this.config.nodeId,
|
|
330
|
+
to: from,
|
|
331
|
+
timestamp: Date.now(),
|
|
332
|
+
payload: { success: false, error: `Model "${payload.model}" not available locally` },
|
|
333
|
+
} satisfies ModelResponse);
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
try {
|
|
338
|
+
// Use OpenClaw's API to run the model locally
|
|
339
|
+
// This is done by calling the local gateway's /v1/chat/completions endpoint
|
|
340
|
+
// The local OpenClaw gateway handles provider routing
|
|
341
|
+
const localUrl = `http://127.0.0.1:${process.env.OPENCLAW_GATEWAY_PORT ?? 3000}/v1/chat/completions`;
|
|
342
|
+
|
|
343
|
+
const response = await fetch(localUrl, {
|
|
344
|
+
method: "POST",
|
|
345
|
+
headers: { "Content-Type": "application/json" },
|
|
346
|
+
body: JSON.stringify({
|
|
347
|
+
model: `${model.provider}/${model.id}`,
|
|
348
|
+
messages: payload.messages,
|
|
349
|
+
temperature: payload.temperature,
|
|
350
|
+
max_tokens: payload.maxTokens,
|
|
351
|
+
stream: payload.stream,
|
|
352
|
+
}),
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
if (payload.stream) {
|
|
356
|
+
// Read SSE stream and forward as model_stream frames
|
|
357
|
+
const reader = response.body?.getReader();
|
|
358
|
+
if (!reader) throw new Error("No response body");
|
|
359
|
+
|
|
360
|
+
const decoder = new TextDecoder();
|
|
361
|
+
let buffer = "";
|
|
362
|
+
|
|
363
|
+
while (true) {
|
|
364
|
+
const { done, value } = await reader.read();
|
|
365
|
+
if (done) break;
|
|
366
|
+
|
|
367
|
+
buffer += decoder.decode(value, { stream: true });
|
|
368
|
+
const lines = buffer.split("\n");
|
|
369
|
+
buffer = lines.pop()!;
|
|
370
|
+
|
|
371
|
+
for (const line of lines) {
|
|
372
|
+
if (!line.startsWith("data: ")) continue;
|
|
373
|
+
const data = line.slice(6).trim();
|
|
374
|
+
if (data === "[DONE]") {
|
|
375
|
+
this.peerManager.sendTo(from, {
|
|
376
|
+
type: "model_stream",
|
|
377
|
+
id,
|
|
378
|
+
from: this.config.nodeId,
|
|
379
|
+
to: from,
|
|
380
|
+
timestamp: Date.now(),
|
|
381
|
+
payload: { delta: "", done: true },
|
|
382
|
+
} satisfies ModelStreamChunk);
|
|
383
|
+
break;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
try {
|
|
387
|
+
const parsed = JSON.parse(data);
|
|
388
|
+
const delta = parsed.choices?.[0]?.delta?.content ?? "";
|
|
389
|
+
if (delta) {
|
|
390
|
+
this.peerManager.sendTo(from, {
|
|
391
|
+
type: "model_stream",
|
|
392
|
+
id,
|
|
393
|
+
from: this.config.nodeId,
|
|
394
|
+
to: from,
|
|
395
|
+
timestamp: Date.now(),
|
|
396
|
+
payload: { delta, done: false },
|
|
397
|
+
} satisfies ModelStreamChunk);
|
|
398
|
+
}
|
|
399
|
+
} catch {
|
|
400
|
+
// skip malformed chunks
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
} else {
|
|
405
|
+
const result = (await response.json()) as {
|
|
406
|
+
choices?: { message?: { content?: string } }[];
|
|
407
|
+
usage?: { prompt_tokens: number; completion_tokens: number };
|
|
408
|
+
};
|
|
409
|
+
const content = result.choices?.[0]?.message?.content ?? "";
|
|
410
|
+
const usage = result.usage;
|
|
411
|
+
|
|
412
|
+
this.peerManager.sendTo(from, {
|
|
413
|
+
type: "model_res",
|
|
414
|
+
id,
|
|
415
|
+
from: this.config.nodeId,
|
|
416
|
+
to: from,
|
|
417
|
+
timestamp: Date.now(),
|
|
418
|
+
payload: {
|
|
419
|
+
success: true,
|
|
420
|
+
content,
|
|
421
|
+
usage: usage
|
|
422
|
+
? { inputTokens: usage.prompt_tokens, outputTokens: usage.completion_tokens }
|
|
423
|
+
: undefined,
|
|
424
|
+
},
|
|
425
|
+
} satisfies ModelResponse);
|
|
426
|
+
}
|
|
427
|
+
} catch (err) {
|
|
428
|
+
this.peerManager.sendTo(from, {
|
|
429
|
+
type: "model_res",
|
|
430
|
+
id,
|
|
431
|
+
from: this.config.nodeId,
|
|
432
|
+
to: from,
|
|
433
|
+
timestamp: Date.now(),
|
|
434
|
+
payload: {
|
|
435
|
+
success: false,
|
|
436
|
+
error: err instanceof Error ? err.message : String(err),
|
|
437
|
+
},
|
|
438
|
+
} satisfies ModelResponse);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|