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 +178 -0
- package/bin/clawmux.cjs +2 -0
- package/clawmux.example.json +33 -0
- package/dist/cli.cjs +376 -0
- package/dist/index.cjs +243 -0
- package/package.json +49 -0
package/README.md
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+

|
|
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
|
package/bin/clawmux.cjs
ADDED
|
@@ -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
|
+
}
|