clawmux 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 ADDED
@@ -0,0 +1,178 @@
1
+ ![ClawMux logo](./docs/images/clawmux.png)
2
+ # ClawMux
3
+
4
+ Smart model routing + context compression proxy for OpenClaw.
5
+
6
+ ## Features
7
+
8
+ - 🧠 **Smart Routing**: Embedding-based semantic classification → LIGHT/MEDIUM/HEAVY tier → automatic model selection
9
+ - 📦 **Context Compression**: Preemptive background summarization at configurable threshold (default 75%)
10
+ - 🔌 **All Providers**: Supports all OpenClaw providers via 6 API format adapters
11
+ - ⚡ **Zero Config Auth**: Uses OpenClaw's existing provider credentials — no separate API keys
12
+ - 📊 **Cost Tracking**: Real-time savings stats at /stats endpoint
13
+ - 🔄 **Hot Reload**: Config changes apply without restart
14
+
15
+ ## Quick Start
16
+
17
+ Requires [Bun](https://bun.sh) or [Node.js](https://nodejs.org) (18+) and a working OpenClaw installation.
18
+
19
+ ```bash
20
+ # Clone and install
21
+ git clone https://github.com/your-org/ClawMux
22
+ cd ClawMux
23
+ bash scripts/install.sh
24
+ ```
25
+
26
+ The install script:
27
+ 1. Detects your OpenClaw config at `~/.openclaw/openclaw.json` (override with `OPENCLAW_CONFIG_PATH`)
28
+ 2. Creates `clawmux.json` from the example if it doesn't exist
29
+ 3. Registers ClawMux as a provider in your OpenClaw config
30
+
31
+ Then start the proxy:
32
+
33
+ ```bash
34
+ # Bun (recommended — faster startup & runtime)
35
+ bun run dev # watch mode (development)
36
+ bun run start # production
37
+
38
+ # Node.js
39
+ npm run start:node # requires tsx: npm i -D tsx
40
+ ```
41
+
42
+ Select a provider in OpenClaw and start chatting:
43
+
44
+ ```bash
45
+ openclaw provider clawmux-anthropic
46
+ openclaw chat
47
+ ```
48
+
49
+ ## Configuration
50
+
51
+ Copy `clawmux.example.json` to `clawmux.json` and adjust as needed:
52
+
53
+ ```jsonc
54
+ {
55
+ "compression": {
56
+ "threshold": 0.75, // trigger compression at 75% of context window
57
+ "model": "anthropic/claude-3-5-haiku-20241022", // model used for summarization (provider/model format)
58
+ "targetRatio": 0.6 // compress to 60% of original token count
59
+ },
60
+ "routing": {
61
+ "models": {
62
+ "LIGHT": "anthropic/claude-3-5-haiku-20241022",
63
+ "MEDIUM": "anthropic/claude-sonnet-4-20250514",
64
+ "HEAVY": "anthropic/claude-opus-4-20250514"
65
+ // Model IDs use 'provider/model' format. Do NOT use provider names starting with "clawmux-" — causes infinite loops
66
+ },
67
+ "scoring": {
68
+ "confidenceThreshold": 0.7 // classification confidence below this → fallback to MEDIUM tier
69
+ }
70
+ },
71
+ "server": {
72
+ "port": 3456,
73
+ "host": "127.0.0.1"
74
+ }
75
+ }
76
+ ```
77
+
78
+ Config is watched for changes. Edit `clawmux.json` while the proxy is running and it reloads automatically.
79
+
80
+ ### Cross-Provider Routing
81
+
82
+ Mix models from different providers by tier. ClawMux automatically translates request and response formats between providers:
83
+
84
+ ```jsonc
85
+ {
86
+ "routing": {
87
+ "models": {
88
+ "LIGHT": "zai/glm-5", // ZAI (openai-completions)
89
+ "MEDIUM": "anthropic/claude-sonnet-4-20250514", // Anthropic (anthropic-messages)
90
+ "HEAVY": "openai/gpt-5.4" // OpenAI (openai-completions)
91
+ }
92
+ }
93
+ }
94
+ ```
95
+
96
+ All three providers must be configured in your `openclaw.json`. ClawMux handles format translation transparently — a request arriving in Anthropic format gets translated to OpenAI format when routed to GPT, and the response is translated back to Anthropic format before returning to OpenClaw.
97
+
98
+ Supported translation pairs: Anthropic ↔ OpenAI ↔ Google ↔ Ollama ↔ Bedrock (all combinations).
99
+
100
+ ## Supported Providers
101
+
102
+ ClawMux registers itself as six providers in OpenClaw, one per API format:
103
+
104
+ | API Format | Providers |
105
+ |---|---|
106
+ | `anthropic-messages` | Anthropic, Synthetic, Kimi Coding |
107
+ | `openai-completions` | OpenAI, Moonshot, ZAI, Cerebras, vLLM, SGLang, LM Studio, OpenRouter, Together, NVIDIA, Venice, Groq, Mistral, xAI, HuggingFace, Cloudflare, Volcengine, BytePlus, Vercel, Kilocode, Qianfan, ModelStudio, MiniMax, Xiaomi |
108
+ | `openai-responses` | OpenAI (newer), OpenAI Codex |
109
+ | `google-generative-ai` | Google Gemini, Google Vertex |
110
+ | `ollama` | Ollama |
111
+ | `bedrock-converse-stream` | AWS Bedrock |
112
+
113
+ Use `clawmux-anthropic`, `clawmux-openai`, `clawmux-openai-responses`, `clawmux-google`, `clawmux-ollama`, or `clawmux-bedrock` as the provider name in OpenClaw.
114
+
115
+ ## How It Works
116
+
117
+ ```
118
+ OpenClaw → ClawMux Proxy (localhost:3456) → Upstream Provider(s)
119
+ │
120
+ ├── 1. Classify complexity (embedding model, ~4ms first run, <1ms cached)
121
+ ├── 2. Select tier → LIGHT/MEDIUM/HEAVY
122
+ ├── 3. Compress context if threshold exceeded
123
+ ├── 4. Translate request format if cross-provider
124
+ ├── 5. Forward to upstream with correct model
125
+ └── 6. Translate response back to original format
126
+ ```
127
+
128
+ **Routing tiers** map to model IDs you configure. A local embedding model (`Xenova/paraphrase-multilingual-MiniLM-L12-v2`) classifies the semantic complexity of each request using nearest-centroid classification (~4ms first run, <1ms cached), supporting both Korean and English. Short queries are detected by a lightweight heuristic and routed to LIGHT tier directly. No external API calls are needed for classification.
129
+
130
+ **Low confidence fallback**: When the classifier's confidence falls below `confidenceThreshold` (default 0.7), the request is routed to MEDIUM tier regardless of the computed score. This prevents unreliable classifications from sending requests to an inappropriate tier — MEDIUM provides a safe cost/quality balance compared to risking unnecessary cost (HEAVY) or degraded quality (LIGHT).
131
+
132
+ **Context compression** runs in the background after each response. When the conversation approaches the configured threshold, ClawMux summarizes older messages before the next request goes out. This keeps costs down on long conversations without interrupting the flow.
133
+
134
+ ### Context Window Resolution
135
+
136
+ ClawMux resolves each model's context window using this priority chain:
137
+
138
+ 1. **clawmux.json** `routing.contextWindows` — explicit per-model override
139
+ 2. **openclaw.json** `models.providers[provider].models[].contextWindow` — user config
140
+ 3. **OpenClaw built-in catalog** — pi-ai model database (812+ models)
141
+ 4. **Default: 200,000 tokens**
142
+
143
+ Compression threshold uses the **minimum** context window across all routing models, since compression happens before routing decides which model to use.
144
+
145
+ ## API Endpoints
146
+
147
+ | Method | Path | Description |
148
+ |---|---|---|
149
+ | `GET` | `/health` | Health check |
150
+ | `GET` | `/stats` | Cost savings statistics |
151
+ | `POST` | `/v1/messages` | Anthropic Messages |
152
+ | `POST` | `/v1/chat/completions` | OpenAI Chat Completions |
153
+ | `POST` | `/v1/responses` | OpenAI Responses |
154
+ | `POST` | `/v1beta/models/*` | Google Generative AI |
155
+ | `POST` | `/api/chat` | Ollama |
156
+ | `POST` | `/model/*/converse-stream` | Bedrock |
157
+
158
+ ## Development
159
+
160
+ ```bash
161
+ bun run dev # start with watch mode
162
+ bun test # run all tests
163
+ bun run typecheck # type check without emit
164
+ ```
165
+
166
+ Tests are co-located with source files as `*.test.ts`.
167
+
168
+ ## Uninstall
169
+
170
+ ```bash
171
+ bash scripts/uninstall.sh
172
+ ```
173
+
174
+ Removes all `clawmux-*` providers from your OpenClaw config. Your original config is backed up before any changes.
175
+
176
+ ## License
177
+
178
+ MIT
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ require("../dist/cli.cjs");
@@ -0,0 +1,33 @@
1
+ {
2
+ "_comment": "ClawMux configuration — copy this file to clawmux.json and adjust values.",
3
+ "compression": {
4
+ "threshold": 0.75,
5
+ "_comment_threshold": "Token ratio (0.1–0.95) that triggers context compression.",
6
+ "model": "anthropic/claude-3-5-haiku-20241022",
7
+ "_comment_model": "Model ID in 'provider/model' format used for the compression summarisation call.",
8
+ "targetRatio": 0.6,
9
+ "_comment_targetRatio": "Desired compression ratio (0.2–0.9). 0.6 = compress to 60% of original."
10
+ },
11
+ "routing": {
12
+ "models": {
13
+ "LIGHT": "anthropic/claude-3-5-haiku-20241022",
14
+ "MEDIUM": "anthropic/claude-sonnet-4-20250514",
15
+ "HEAVY": "anthropic/claude-opus-4-20250514",
16
+ "_comment_models": "Model IDs in 'provider/model' format for each routing tier. Do NOT use provider names starting with 'clawmux-' — this causes infinite loops."
17
+ },
18
+ "scoring": {
19
+ "confidenceThreshold": 0.7,
20
+ "_comment_confidenceThreshold": "Classification confidence below this threshold triggers a fallback to MEDIUM tier (range: 0.0–1.0)."
21
+ },
22
+ "contextWindows": {
23
+ "_comment_contextWindows": "Optional per-model context window overrides (in tokens). Keys use 'provider/model' format.",
24
+ "zai/glm-5": 204800,
25
+ "openai/gpt-5.4": 400000
26
+ }
27
+ },
28
+ "server": {
29
+ "port": 3456,
30
+ "host": "127.0.0.1",
31
+ "_comment_server": "Proxy listen address. Port range: 1024–65535."
32
+ }
33
+ }
package/dist/cli.cjs ADDED
@@ -0,0 +1,376 @@
1
+ #!/usr/bin/env node
2
+ var __defProp = Object.defineProperty;
3
+ var __export = (target, all) => {
4
+ for (var name in all)
5
+ __defProp(target, name, {
6
+ get: all[name],
7
+ enumerable: true,
8
+ configurable: true,
9
+ set: (newValue) => all[name] = () => newValue
10
+ });
11
+ };
12
+
13
+ // src/proxy/node-http-adapter.ts
14
+ var exports_node_http_adapter = {};
15
+ __export(exports_node_http_adapter, {
16
+ writeWebResponse: () => writeWebResponse,
17
+ toWebRequest: () => toWebRequest
18
+ });
19
+ function toWebRequest(req) {
20
+ const protocol = "http";
21
+ const host = req.headers.host ?? "localhost";
22
+ const url = `${protocol}://${host}${req.url ?? "/"}`;
23
+ const headers = new Headers;
24
+ for (const [key, value] of Object.entries(req.headers)) {
25
+ if (value === undefined)
26
+ continue;
27
+ if (Array.isArray(value)) {
28
+ for (const v of value)
29
+ headers.append(key, v);
30
+ } else {
31
+ headers.set(key, value);
32
+ }
33
+ }
34
+ const method = (req.method ?? "GET").toUpperCase();
35
+ const hasBody = method !== "GET" && method !== "HEAD";
36
+ const init = {
37
+ method,
38
+ headers,
39
+ body: hasBody ? toReadableStream(req) : undefined
40
+ };
41
+ if (hasBody)
42
+ init.duplex = "half";
43
+ return new Request(url, init);
44
+ }
45
+ function toReadableStream(req) {
46
+ return new ReadableStream({
47
+ start(controller) {
48
+ req.on("data", (chunk) => controller.enqueue(new Uint8Array(chunk)));
49
+ req.on("end", () => controller.close());
50
+ req.on("error", (err) => controller.error(err));
51
+ }
52
+ });
53
+ }
54
+ async function writeWebResponse(res, response) {
55
+ res.writeHead(response.status, Object.fromEntries(response.headers.entries()));
56
+ if (!response.body) {
57
+ res.end();
58
+ return;
59
+ }
60
+ const reader = response.body.getReader();
61
+ try {
62
+ for (;; ) {
63
+ const { done, value } = await reader.read();
64
+ if (done)
65
+ break;
66
+ const flushed = res.write(value);
67
+ if (!flushed) {
68
+ await new Promise((resolve) => res.once("drain", resolve));
69
+ }
70
+ }
71
+ } finally {
72
+ reader.releaseLock();
73
+ res.end();
74
+ }
75
+ }
76
+
77
+ // src/cli.ts
78
+ var import_promises = require("node:fs/promises");
79
+ var import_node_path = require("node:path");
80
+
81
+ // src/proxy/router.ts
82
+ var VERSION = "0.1.0";
83
+ function jsonResponse(body, status = 200) {
84
+ return new Response(JSON.stringify(body), {
85
+ status,
86
+ headers: { "content-type": "application/json" }
87
+ });
88
+ }
89
+ function stubNotImplemented(name) {
90
+ return async () => jsonResponse({ error: `${name} not implemented yet` }, 501);
91
+ }
92
+ var customHandlers = new Map;
93
+ var routes = [
94
+ {
95
+ method: "GET",
96
+ match: (p) => p === "/health",
97
+ handler: async () => jsonResponse({ status: "ok", version: VERSION }),
98
+ key: "/health"
99
+ },
100
+ {
101
+ method: "GET",
102
+ match: (p) => p === "/stats",
103
+ handler: async () => jsonResponse({ message: "stats not implemented yet" }),
104
+ key: "/stats"
105
+ },
106
+ {
107
+ method: "POST",
108
+ match: (p) => p === "/v1/messages",
109
+ handler: stubNotImplemented("anthropic"),
110
+ key: "/v1/messages"
111
+ },
112
+ {
113
+ method: "POST",
114
+ match: (p) => p === "/v1/chat/completions",
115
+ handler: stubNotImplemented("openai-completions"),
116
+ key: "/v1/chat/completions"
117
+ },
118
+ {
119
+ method: "POST",
120
+ match: (p) => p === "/v1/responses",
121
+ handler: stubNotImplemented("openai-responses"),
122
+ key: "/v1/responses"
123
+ },
124
+ {
125
+ method: "POST",
126
+ match: (p) => p.startsWith("/v1beta/models/"),
127
+ handler: stubNotImplemented("google"),
128
+ key: "/v1beta/models/*"
129
+ },
130
+ {
131
+ method: "POST",
132
+ match: (p) => p === "/api/chat",
133
+ handler: stubNotImplemented("ollama"),
134
+ key: "/api/chat"
135
+ },
136
+ {
137
+ method: "POST",
138
+ match: (p) => p.startsWith("/model/") && p.endsWith("/converse-stream"),
139
+ handler: stubNotImplemented("bedrock"),
140
+ key: "/model/*/converse-stream"
141
+ }
142
+ ];
143
+ async function parseJsonBody(req) {
144
+ const contentType = req.headers.get("content-type") ?? "";
145
+ if (!contentType.includes("application/json")) {
146
+ return { body: null, error: null };
147
+ }
148
+ try {
149
+ const text = await req.text();
150
+ if (text.length === 0) {
151
+ return { body: null, error: null };
152
+ }
153
+ return { body: JSON.parse(text), error: null };
154
+ } catch {
155
+ return {
156
+ body: null,
157
+ error: jsonResponse({ error: "invalid JSON body" }, 400)
158
+ };
159
+ }
160
+ }
161
+ async function dispatch(req) {
162
+ const url = new URL(req.url);
163
+ const { pathname } = url;
164
+ const method = req.method.toUpperCase();
165
+ for (const route of routes) {
166
+ if (route.method === method && route.match(pathname)) {
167
+ const handler = customHandlers.get(route.key) ?? route.handler;
168
+ if (method === "POST") {
169
+ const { body, error } = await parseJsonBody(req);
170
+ if (error)
171
+ return error;
172
+ return handler(req, body);
173
+ }
174
+ return handler(req, null);
175
+ }
176
+ }
177
+ return jsonResponse({ error: "not found" }, 404);
178
+ }
179
+
180
+ // src/utils/runtime.ts
181
+ var isBun = typeof globalThis.Bun !== "undefined";
182
+
183
+ // src/proxy/server.ts
184
+ function createServer(config) {
185
+ if (isBun) {
186
+ return createBunServer(config);
187
+ }
188
+ return createNodeServer(config);
189
+ }
190
+ function createBunServer(config) {
191
+ let server = null;
192
+ return {
193
+ start() {
194
+ if (server)
195
+ return;
196
+ const bun = globalThis.Bun;
197
+ server = bun.serve({
198
+ port: config.port,
199
+ hostname: config.host,
200
+ fetch: dispatch
201
+ });
202
+ },
203
+ stop() {
204
+ if (!server)
205
+ return;
206
+ server.stop(true);
207
+ server = null;
208
+ }
209
+ };
210
+ }
211
+ function createNodeServer(config) {
212
+ let server = null;
213
+ return {
214
+ async start() {
215
+ if (server)
216
+ return;
217
+ const { createServer: createHttpServer } = await import("node:http");
218
+ const { toWebRequest: toWebRequest2, writeWebResponse: writeWebResponse2 } = await Promise.resolve().then(() => exports_node_http_adapter);
219
+ const httpServer = createHttpServer(async (req, res) => {
220
+ try {
221
+ const webReq = toWebRequest2(req);
222
+ const webRes = await dispatch(webReq);
223
+ await writeWebResponse2(res, webRes);
224
+ } catch (err) {
225
+ const message = err instanceof Error ? err.message : String(err);
226
+ res.writeHead(500, { "content-type": "application/json" });
227
+ res.end(JSON.stringify({ error: message }));
228
+ }
229
+ });
230
+ await new Promise((resolve) => {
231
+ httpServer.listen(config.port, config.host, resolve);
232
+ });
233
+ server = httpServer;
234
+ },
235
+ stop() {
236
+ if (!server)
237
+ return;
238
+ server.close();
239
+ server = null;
240
+ }
241
+ };
242
+ }
243
+
244
+ // src/cli.ts
245
+ var VERSION2 = process.env.npm_package_version ?? "0.1.0";
246
+ var HELP = `Usage: clawmux <command>
247
+
248
+ Commands:
249
+ init Detect OpenClaw config, create clawmux.json, register providers
250
+ start Start the proxy server (foreground)
251
+ version Print version
252
+ help Show this help message
253
+
254
+ Options:
255
+ --port, -p <port> Override server port (default: 3456)
256
+
257
+ Environment:
258
+ CLAWMUX_PORT Server port override
259
+ OPENCLAW_CONFIG_PATH Path to openclaw.json`;
260
+ var PROVIDERS = [
261
+ { key: "clawmux-anthropic", api: "anthropic-messages" },
262
+ { key: "clawmux-openai", api: "openai-completions" },
263
+ { key: "clawmux-openai-responses", api: "openai-responses" },
264
+ { key: "clawmux-google", api: "google-generative-ai" },
265
+ { key: "clawmux-ollama", api: "ollama" },
266
+ { key: "clawmux-bedrock", api: "bedrock-converse-stream" }
267
+ ];
268
+ async function fileExistsLocal(path) {
269
+ try {
270
+ await import_promises.access(path);
271
+ return true;
272
+ } catch {
273
+ return false;
274
+ }
275
+ }
276
+ async function init() {
277
+ const homeDir = process.env.HOME ?? "/root";
278
+ const openclawConfigPath = process.env.OPENCLAW_CONFIG_PATH ?? import_node_path.join(homeDir, ".openclaw", "openclaw.json");
279
+ if (!await fileExistsLocal(openclawConfigPath)) {
280
+ console.error(`[error] OpenClaw config not found at ${openclawConfigPath}`);
281
+ console.error("Set OPENCLAW_CONFIG_PATH or ensure ~/.openclaw/openclaw.json exists");
282
+ process.exit(1);
283
+ }
284
+ console.log(`[info] Using OpenClaw config: ${openclawConfigPath}`);
285
+ const backupPath = `${openclawConfigPath}.bak.${Date.now()}`;
286
+ await import_promises.copyFile(openclawConfigPath, backupPath);
287
+ console.log(`[info] Backup created: ${backupPath}`);
288
+ const clawmuxJsonPath = import_node_path.join(process.cwd(), "clawmux.json");
289
+ const examplePath = import_node_path.join(process.cwd(), "clawmux.example.json");
290
+ if (!await fileExistsLocal(clawmuxJsonPath)) {
291
+ if (await fileExistsLocal(examplePath)) {
292
+ await import_promises.copyFile(examplePath, clawmuxJsonPath);
293
+ console.log("[info] Created clawmux.json from clawmux.example.json");
294
+ } else {
295
+ console.warn("[warn] clawmux.json not found and no clawmux.example.json to copy from");
296
+ }
297
+ }
298
+ const raw = await import_promises.readFile(openclawConfigPath, "utf-8");
299
+ const config = JSON.parse(raw);
300
+ if (!config.models)
301
+ config.models = {};
302
+ const models = config.models;
303
+ if (!models.providers)
304
+ models.providers = {};
305
+ const providers = models.providers;
306
+ let added = 0;
307
+ for (const { key, api } of PROVIDERS) {
308
+ if (providers[key]) {
309
+ console.log(` skip ${key} (already exists)`);
310
+ continue;
311
+ }
312
+ providers[key] = {
313
+ baseUrl: "http://localhost:3456",
314
+ api,
315
+ models: [{ id: "auto", name: "ClawMux Auto Router" }]
316
+ };
317
+ added++;
318
+ console.log(` added ${key}`);
319
+ }
320
+ if (added > 0) {
321
+ await import_promises.writeFile(openclawConfigPath, JSON.stringify(config, null, 2) + `
322
+ `);
323
+ console.log(`
324
+ Added ${added} provider(s) to openclaw.json`);
325
+ } else {
326
+ console.log(`
327
+ All ClawMux providers already registered.`);
328
+ }
329
+ console.log(`
330
+ [info] ClawMux provider registration complete!`);
331
+ console.log(`
332
+ Next steps:`);
333
+ console.log(" 1. Edit clawmux.json to configure your models");
334
+ console.log(" 2. Run: clawmux start");
335
+ console.log(" 3. Select a provider: openclaw provider clawmux-openai");
336
+ console.log(" 4. Start chatting: openclaw chat");
337
+ }
338
+ function start() {
339
+ const args = process.argv.slice(2);
340
+ let port = parseInt(process.env.CLAWMUX_PORT ?? "3456", 10);
341
+ const portIdx = args.indexOf("--port") !== -1 ? args.indexOf("--port") : args.indexOf("-p");
342
+ if (portIdx !== -1 && args[portIdx + 1]) {
343
+ port = parseInt(args[portIdx + 1], 10);
344
+ }
345
+ const server = createServer({ port, host: "127.0.0.1" });
346
+ server.start();
347
+ console.log(`[clawmux] Proxy server running on http://127.0.0.1:${port}`);
348
+ }
349
+ var command = process.argv[2];
350
+ switch (command) {
351
+ case "init":
352
+ init().catch((err) => {
353
+ console.error(`[error] ${err.message}`);
354
+ process.exit(1);
355
+ });
356
+ break;
357
+ case "start":
358
+ start();
359
+ break;
360
+ case "version":
361
+ case "--version":
362
+ case "-v":
363
+ console.log(VERSION2);
364
+ break;
365
+ case "help":
366
+ case "--help":
367
+ case "-h":
368
+ case undefined:
369
+ console.log(HELP);
370
+ break;
371
+ default:
372
+ console.error(`Unknown command: ${command}
373
+ `);
374
+ console.log(HELP);
375
+ process.exit(1);
376
+ }
package/dist/index.cjs ADDED
@@ -0,0 +1,243 @@
1
+ var __defProp = Object.defineProperty;
2
+ var __export = (target, all) => {
3
+ for (var name in all)
4
+ __defProp(target, name, {
5
+ get: all[name],
6
+ enumerable: true,
7
+ configurable: true,
8
+ set: (newValue) => all[name] = () => newValue
9
+ });
10
+ };
11
+
12
+ // src/proxy/node-http-adapter.ts
13
+ var exports_node_http_adapter = {};
14
+ __export(exports_node_http_adapter, {
15
+ writeWebResponse: () => writeWebResponse,
16
+ toWebRequest: () => toWebRequest
17
+ });
18
+ function toWebRequest(req) {
19
+ const protocol = "http";
20
+ const host = req.headers.host ?? "localhost";
21
+ const url = `${protocol}://${host}${req.url ?? "/"}`;
22
+ const headers = new Headers;
23
+ for (const [key, value] of Object.entries(req.headers)) {
24
+ if (value === undefined)
25
+ continue;
26
+ if (Array.isArray(value)) {
27
+ for (const v of value)
28
+ headers.append(key, v);
29
+ } else {
30
+ headers.set(key, value);
31
+ }
32
+ }
33
+ const method = (req.method ?? "GET").toUpperCase();
34
+ const hasBody = method !== "GET" && method !== "HEAD";
35
+ const init = {
36
+ method,
37
+ headers,
38
+ body: hasBody ? toReadableStream(req) : undefined
39
+ };
40
+ if (hasBody)
41
+ init.duplex = "half";
42
+ return new Request(url, init);
43
+ }
44
+ function toReadableStream(req) {
45
+ return new ReadableStream({
46
+ start(controller) {
47
+ req.on("data", (chunk) => controller.enqueue(new Uint8Array(chunk)));
48
+ req.on("end", () => controller.close());
49
+ req.on("error", (err) => controller.error(err));
50
+ }
51
+ });
52
+ }
53
+ async function writeWebResponse(res, response) {
54
+ res.writeHead(response.status, Object.fromEntries(response.headers.entries()));
55
+ if (!response.body) {
56
+ res.end();
57
+ return;
58
+ }
59
+ const reader = response.body.getReader();
60
+ try {
61
+ for (;; ) {
62
+ const { done, value } = await reader.read();
63
+ if (done)
64
+ break;
65
+ const flushed = res.write(value);
66
+ if (!flushed) {
67
+ await new Promise((resolve) => res.once("drain", resolve));
68
+ }
69
+ }
70
+ } finally {
71
+ reader.releaseLock();
72
+ res.end();
73
+ }
74
+ }
75
+
76
+ // src/proxy/router.ts
77
+ var VERSION = "0.1.0";
78
+ function jsonResponse(body, status = 200) {
79
+ return new Response(JSON.stringify(body), {
80
+ status,
81
+ headers: { "content-type": "application/json" }
82
+ });
83
+ }
84
+ function stubNotImplemented(name) {
85
+ return async () => jsonResponse({ error: `${name} not implemented yet` }, 501);
86
+ }
87
+ var customHandlers = new Map;
88
+ var routes = [
89
+ {
90
+ method: "GET",
91
+ match: (p) => p === "/health",
92
+ handler: async () => jsonResponse({ status: "ok", version: VERSION }),
93
+ key: "/health"
94
+ },
95
+ {
96
+ method: "GET",
97
+ match: (p) => p === "/stats",
98
+ handler: async () => jsonResponse({ message: "stats not implemented yet" }),
99
+ key: "/stats"
100
+ },
101
+ {
102
+ method: "POST",
103
+ match: (p) => p === "/v1/messages",
104
+ handler: stubNotImplemented("anthropic"),
105
+ key: "/v1/messages"
106
+ },
107
+ {
108
+ method: "POST",
109
+ match: (p) => p === "/v1/chat/completions",
110
+ handler: stubNotImplemented("openai-completions"),
111
+ key: "/v1/chat/completions"
112
+ },
113
+ {
114
+ method: "POST",
115
+ match: (p) => p === "/v1/responses",
116
+ handler: stubNotImplemented("openai-responses"),
117
+ key: "/v1/responses"
118
+ },
119
+ {
120
+ method: "POST",
121
+ match: (p) => p.startsWith("/v1beta/models/"),
122
+ handler: stubNotImplemented("google"),
123
+ key: "/v1beta/models/*"
124
+ },
125
+ {
126
+ method: "POST",
127
+ match: (p) => p === "/api/chat",
128
+ handler: stubNotImplemented("ollama"),
129
+ key: "/api/chat"
130
+ },
131
+ {
132
+ method: "POST",
133
+ match: (p) => p.startsWith("/model/") && p.endsWith("/converse-stream"),
134
+ handler: stubNotImplemented("bedrock"),
135
+ key: "/model/*/converse-stream"
136
+ }
137
+ ];
138
+ async function parseJsonBody(req) {
139
+ const contentType = req.headers.get("content-type") ?? "";
140
+ if (!contentType.includes("application/json")) {
141
+ return { body: null, error: null };
142
+ }
143
+ try {
144
+ const text = await req.text();
145
+ if (text.length === 0) {
146
+ return { body: null, error: null };
147
+ }
148
+ return { body: JSON.parse(text), error: null };
149
+ } catch {
150
+ return {
151
+ body: null,
152
+ error: jsonResponse({ error: "invalid JSON body" }, 400)
153
+ };
154
+ }
155
+ }
156
+ async function dispatch(req) {
157
+ const url = new URL(req.url);
158
+ const { pathname } = url;
159
+ const method = req.method.toUpperCase();
160
+ for (const route of routes) {
161
+ if (route.method === method && route.match(pathname)) {
162
+ const handler = customHandlers.get(route.key) ?? route.handler;
163
+ if (method === "POST") {
164
+ const { body, error } = await parseJsonBody(req);
165
+ if (error)
166
+ return error;
167
+ return handler(req, body);
168
+ }
169
+ return handler(req, null);
170
+ }
171
+ }
172
+ return jsonResponse({ error: "not found" }, 404);
173
+ }
174
+
175
+ // src/utils/runtime.ts
176
+ var isBun = typeof globalThis.Bun !== "undefined";
177
+
178
+ // src/proxy/server.ts
179
+ function createServer(config) {
180
+ if (isBun) {
181
+ return createBunServer(config);
182
+ }
183
+ return createNodeServer(config);
184
+ }
185
+ function createBunServer(config) {
186
+ let server = null;
187
+ return {
188
+ start() {
189
+ if (server)
190
+ return;
191
+ const bun = globalThis.Bun;
192
+ server = bun.serve({
193
+ port: config.port,
194
+ hostname: config.host,
195
+ fetch: dispatch
196
+ });
197
+ },
198
+ stop() {
199
+ if (!server)
200
+ return;
201
+ server.stop(true);
202
+ server = null;
203
+ }
204
+ };
205
+ }
206
+ function createNodeServer(config) {
207
+ let server = null;
208
+ return {
209
+ async start() {
210
+ if (server)
211
+ return;
212
+ const { createServer: createHttpServer } = await import("node:http");
213
+ const { toWebRequest: toWebRequest2, writeWebResponse: writeWebResponse2 } = await Promise.resolve().then(() => exports_node_http_adapter);
214
+ const httpServer = createHttpServer(async (req, res) => {
215
+ try {
216
+ const webReq = toWebRequest2(req);
217
+ const webRes = await dispatch(webReq);
218
+ await writeWebResponse2(res, webRes);
219
+ } catch (err) {
220
+ const message = err instanceof Error ? err.message : String(err);
221
+ res.writeHead(500, { "content-type": "application/json" });
222
+ res.end(JSON.stringify({ error: message }));
223
+ }
224
+ });
225
+ await new Promise((resolve) => {
226
+ httpServer.listen(config.port, config.host, resolve);
227
+ });
228
+ server = httpServer;
229
+ },
230
+ stop() {
231
+ if (!server)
232
+ return;
233
+ server.close();
234
+ server = null;
235
+ }
236
+ };
237
+ }
238
+
239
+ // src/index.ts
240
+ var port = parseInt(process.env.CLAWMUX_PORT ?? "3456", 10);
241
+ var server = createServer({ port, host: "127.0.0.1" });
242
+ server.start();
243
+ console.log(`[clawmux] Proxy server running on http://127.0.0.1:${port}`);
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "clawmux",
3
+ "version": "0.1.0",
4
+ "description": "Smart model routing + context compression proxy for OpenClaw",
5
+ "type": "module",
6
+ "bin": {
7
+ "clawmux": "./bin/clawmux.cjs"
8
+ },
9
+ "files": [
10
+ "bin/",
11
+ "dist/",
12
+ "clawmux.example.json",
13
+ "README.md",
14
+ "LICENSE"
15
+ ],
16
+ "engines": {
17
+ "node": ">=18.0.0"
18
+ },
19
+ "scripts": {
20
+ "dev": "bun run --watch src/index.ts",
21
+ "start": "bun run src/index.ts",
22
+ "start:node": "node --import tsx src/index.ts",
23
+ "test": "bun test",
24
+ "typecheck": "tsc --noEmit",
25
+ "build": "bun build src/cli.ts --outfile dist/cli.cjs --target node --format cjs --external @huggingface/transformers && bun build src/index.ts --outfile dist/index.cjs --target node --format cjs --external @huggingface/transformers",
26
+ "build:check": "node dist/cli.cjs version",
27
+ "prepublishOnly": "npm run typecheck && npm run build && npm run build:check"
28
+ },
29
+ "dependencies": {
30
+ "@huggingface/transformers": "^3"
31
+ },
32
+ "devDependencies": {
33
+ "@types/bun": "^1.3.11"
34
+ },
35
+ "repository": {
36
+ "type": "git",
37
+ "url": "git+https://github.com/nagle-app/ClawMux.git"
38
+ },
39
+ "keywords": [
40
+ "llm",
41
+ "proxy",
42
+ "routing",
43
+ "compression",
44
+ "openclaw",
45
+ "ai",
46
+ "model-router"
47
+ ],
48
+ "license": "MIT"
49
+ }