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.
@@ -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
+ }