@elvatis_com/openclaw-cli-bridge-elvatis 1.7.5 → 1.8.1

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 CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  > OpenClaw plugin that bridges locally installed AI CLIs (Codex, Gemini, Claude Code) as model providers — with slash commands for instant model switching, restore, health testing, and model listing.
4
4
 
5
- **Current version:** `1.7.5`
5
+ **Current version:** `1.8.1`
6
6
 
7
7
  ---
8
8
 
@@ -57,6 +57,12 @@ All commands use gateway-level `commands.allowFrom` for authorization (`requireA
57
57
  | `/cli-codex54` | `openai-codex/gpt-5.4` | May require upgraded OAuth scope |
58
58
  | `/cli-codex-mini` | `openai-codex/gpt-5.1-codex-mini` | ✅ Tested |
59
59
 
60
+ **BitNet local inference** (via local proxy → llama-server on 127.0.0.1:8082, no API key):
61
+
62
+ | Command | Model |
63
+ |---|---|
64
+ | `/cli-bitnet` | `vllm/local-bitnet/bitnet-2b` |
65
+
60
66
  **Utility:**
61
67
 
62
68
  | Command | What it does |
@@ -362,6 +368,13 @@ npm test # vitest run (83 tests)
362
368
 
363
369
  ## Changelog
364
370
 
371
+ ### v1.8.1
372
+ - **fix:** `--now` flag now works when followed by additional text (e.g. `/cli-bitnet --now hello`) — was using `===` instead of `startsWith`.
373
+
374
+ ### v1.8.0
375
+ - **feat:** BitNet local inference — `local-bitnet/bitnet-2b` routes to llama-server on 127.0.0.1:8082. No API key, no internet, pure CPU inference (2.87 tok/s on i7-6700K). Use `/cli-bitnet` to switch.
376
+ - **feat:** `/bridge-status` shows BitNet server health as 5th provider.
377
+
365
378
  ### v1.7.5
366
379
  - **chore:** Re-published to ClawHub with correct display name "OpenClaw CLI Bridge"
367
380
 
package/SKILL.md CHANGED
@@ -24,6 +24,7 @@ Registers `openai-codex` provider from existing `~/.codex/auth.json` tokens. No
24
24
  Local OpenAI-compatible HTTP proxy (`127.0.0.1:31337`) routes vllm model calls to CLI subprocesses:
25
25
  - `vllm/cli-gemini/gemini-2.5-pro` / `gemini-2.5-flash` / `gemini-3-pro`
26
26
  - `vllm/cli-claude/claude-sonnet-4-6` / `claude-opus-4-6` / `claude-haiku-4-5`
27
+ - `vllm/local-bitnet/bitnet-2b` → BitNet llama-server on 127.0.0.1:8082
27
28
 
28
29
  Prompts go via stdin/tmpfile — never as CLI args (prevents `E2BIG` for long sessions).
29
30
 
@@ -40,6 +41,7 @@ Six instant model-switch commands (authorized senders only):
40
41
  | `/cli-gemini3` | `vllm/cli-gemini/gemini-3-pro` |
41
42
  | `/cli-codex` | `openai-codex/gpt-5.3-codex` |
42
43
  | `/cli-codex54` | `openai-codex/gpt-5.4` |
44
+ | `/cli-bitnet` | `vllm/local-bitnet/bitnet-2b` |
43
45
  | `/cli-back` | Restore previous model |
44
46
  | `/cli-test [model]` | Health check (no model switch) |
45
47
 
@@ -66,4 +68,4 @@ On gateway restart, if any session has expired, a **WhatsApp alert** is sent aut
66
68
 
67
69
  See `README.md` for full configuration reference and architecture diagram.
68
70
 
69
- **Version:** 1.7.5
71
+ **Version:** 1.8.1
package/index.ts CHANGED
@@ -705,6 +705,25 @@ function readCurrentModel(): string | null {
705
705
  }
706
706
  }
707
707
 
708
+ // ──────────────────────────────────────────────────────────────────────────────
709
+ // BitNet server health check
710
+ // ──────────────────────────────────────────────────────────────────────────────
711
+ async function checkBitNetServer(url = "http://127.0.0.1:8082"): Promise<boolean> {
712
+ return new Promise((resolve) => {
713
+ const target = new URL("/v1/models", url);
714
+ const req = http.get(
715
+ { hostname: target.hostname, port: parseInt(target.port), path: target.pathname, timeout: 3_000 },
716
+ (res) => {
717
+ let data = "";
718
+ res.on("data", (c: Buffer) => (data += c));
719
+ res.on("end", () => resolve(res.statusCode === 200));
720
+ }
721
+ );
722
+ req.on("error", () => resolve(false));
723
+ req.on("timeout", () => { req.destroy(); resolve(false); });
724
+ });
725
+ }
726
+
708
727
  // ──────────────────────────────────────────────────────────────────────────────
709
728
  // Phase 3: model command table
710
729
  // ──────────────────────────────────────────────────────────────────────────────
@@ -720,6 +739,8 @@ const CLI_MODEL_COMMANDS = [
720
739
  // ── Codex CLI (openai-codex provider, OAuth auth) ────────────────────────────
721
740
  { name: "cli-codex", model: "openai-codex/gpt-5.3-codex", description: "GPT-5.3 Codex (Codex CLI auth)", label: "GPT-5.3 Codex" },
722
741
  { name: "cli-codex54", model: "openai-codex/gpt-5.4", description: "GPT-5.4 (Codex CLI auth)", label: "GPT-5.4" },
742
+ // ── BitNet local inference (via local proxy → llama-server) ─────────────────
743
+ { name: "cli-bitnet", model: "vllm/local-bitnet/bitnet-2b", description: "BitNet b1.58 2B (local CPU, no API key)", label: "BitNet 2B (local)" },
723
744
  ] as const;
724
745
 
725
746
  /** Default model used by /cli-test when no arg is given */
@@ -918,7 +939,7 @@ function proxyTestRequest(
918
939
  const plugin = {
919
940
  id: "openclaw-cli-bridge-elvatis",
920
941
  name: "OpenClaw CLI Bridge",
921
- version: "1.7.5",
942
+ version: "1.8.1",
922
943
  description:
923
944
  "Phase 1: openai-codex auth bridge. " +
924
945
  "Phase 2: HTTP proxy for gemini/claude CLIs. " +
@@ -1387,7 +1408,7 @@ const plugin = {
1387
1408
  acceptsArgs: true,
1388
1409
  requireAuth: false,
1389
1410
  handler: async (ctx: PluginCommandContext): Promise<PluginCommandResult> => {
1390
- const forceNow = (ctx.args ?? "").trim().toLowerCase() === "--now";
1411
+ const forceNow = (ctx.args ?? "").trim().toLowerCase().startsWith("--now");
1391
1412
  api.logger.info(`[cli-bridge] /${name} by ${ctx.senderId ?? "?"} forceNow=${forceNow}`);
1392
1413
  return switchModel(api, model, label, forceNow);
1393
1414
  },
@@ -2152,6 +2173,16 @@ const plugin = {
2152
2173
  lines.push("");
2153
2174
  }
2154
2175
 
2176
+ // ── BitNet local inference ──────────────────────────────────────────────
2177
+ const bitnetOk = await checkBitNetServer();
2178
+ if (bitnetOk) {
2179
+ lines.push(`✅ *BitNet (local)* — running at 127.0.0.1:8082`);
2180
+ lines.push(` Models: local-bitnet/bitnet-2b`);
2181
+ } else {
2182
+ lines.push(`❌ *BitNet (local)* — not running (\`sudo systemctl start bitnet-server\`)`);
2183
+ }
2184
+ lines.push("");
2185
+
2155
2186
  lines.push(`🔌 Proxy: \`127.0.0.1:${port}\``);
2156
2187
  return { text: lines.join("\n") };
2157
2188
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "openclaw-cli-bridge-elvatis",
3
3
  "name": "OpenClaw CLI Bridge",
4
- "version": "1.7.5",
4
+ "version": "1.8.1",
5
5
  "license": "MIT",
6
6
  "description": "Phase 1: openai-codex auth bridge. Phase 2: local HTTP proxy routing model calls through gemini/claude CLIs (vllm provider).",
7
7
  "providers": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elvatis_com/openclaw-cli-bridge-elvatis",
3
- "version": "1.7.5",
3
+ "version": "1.8.1",
4
4
  "description": "Bridges gemini, claude, and codex CLI tools as OpenClaw model providers. Reads existing CLI auth without re-login.",
5
5
  "type": "module",
6
6
  "openclaw": {
@@ -69,6 +69,8 @@ export interface ProxyServerOptions {
69
69
  };
70
70
  /** Plugin version string for the status page */
71
71
  version?: string;
72
+ /** Returns the BitNet llama-server base URL (default: http://127.0.0.1:8082) */
73
+ getBitNetServerUrl?: () => string;
72
74
  }
73
75
 
74
76
  /** Available CLI bridge models for GET /v1/models */
@@ -104,6 +106,8 @@ export const CLI_MODELS = [
104
106
  { id: "web-chatgpt/o4-mini", name: "o4-mini (web session)", contextWindow: 200_000, maxTokens: 100_000 },
105
107
  { id: "web-chatgpt/gpt-5", name: "GPT-5 (web session)", contextWindow: 1_047_576, maxTokens: 32_768 },
106
108
  { id: "web-chatgpt/gpt-5-mini", name: "GPT-5 Mini (web session)", contextWindow: 1_047_576, maxTokens: 32_768 },
109
+ // ── Local BitNet inference ──────────────────────────────────────────────────
110
+ { id: "local-bitnet/bitnet-2b", name: "BitNet b1.58 2B (local CPU inference)", contextWindow: 4_096, maxTokens: 2_048 },
107
111
  ];
108
112
 
109
113
  // ──────────────────────────────────────────────────────────────────────────────
@@ -206,6 +210,7 @@ async function handleRequest(
206
210
 
207
211
  const cliModels = CLI_MODELS.filter(m => m.id.startsWith("cli-"));
208
212
  const webModels = CLI_MODELS.filter(m => m.id.startsWith("web-"));
213
+ const localModels = CLI_MODELS.filter(m => m.id.startsWith("local-"));
209
214
  const modelList = (models: typeof CLI_MODELS) =>
210
215
  models.map(m => `<li style="margin:2px 0;font-size:13px;color:#d1d5db"><code style="color:#93c5fd">${m.id}</code></li>`).join("");
211
216
 
@@ -259,6 +264,10 @@ async function handleRequest(
259
264
  <div class="card-header">Web Session Models (${webModels.length})</div>
260
265
  <ul>${modelList(webModels)}</ul>
261
266
  </div>
267
+ <div class="card">
268
+ <div class="card-header">Local Models (${localModels.length})</div>
269
+ <ul>${modelList(localModels)}</ul>
270
+ </div>
262
271
  </div>
263
272
 
264
273
  <p class="footer">openclaw-cli-bridge-elvatis v${version} &nbsp;·&nbsp; <a href="/v1/models" style="color:#4b5563">/v1/models</a> &nbsp;·&nbsp; <a href="/health" style="color:#4b5563">/health</a></p>
@@ -284,7 +293,7 @@ async function handleRequest(
284
293
  owned_by: "openclaw-cli-bridge",
285
294
  // CLI-proxy models stream plain text — no tool/function call support
286
295
  capabilities: {
287
- tools: !(m.id.startsWith("cli-gemini/") || m.id.startsWith("cli-claude/")),
296
+ tools: !(m.id.startsWith("cli-gemini/") || m.id.startsWith("cli-claude/") || m.id.startsWith("local-bitnet/")),
288
297
  },
289
298
  })),
290
299
  })
@@ -332,7 +341,7 @@ async function handleRequest(
332
341
  // CLI-proxy models (cli-gemini/*, cli-claude/*) are plain text completions —
333
342
  // they cannot process tool/function call schemas. Return a clear 400 so
334
343
  // OpenClaw can surface a meaningful error instead of getting a garbled response.
335
- const isCliModel = model.startsWith("cli-gemini/") || model.startsWith("cli-claude/");
344
+ const isCliModel = model.startsWith("cli-gemini/") || model.startsWith("cli-claude/") || model.startsWith("local-bitnet/");
336
345
  if (hasTools && isCliModel) {
337
346
  res.writeHead(400, { "Content-Type": "application/json" });
338
347
  res.end(JSON.stringify({
@@ -549,6 +558,65 @@ async function handleRequest(
549
558
  }
550
559
  // ─────────────────────────────────────────────────────────────────────────
551
560
 
561
+ // ── BitNet local inference routing ────────────────────────────────────────
562
+ if (model.startsWith("local-bitnet/")) {
563
+ const bitnetUrl = opts.getBitNetServerUrl?.() ?? "http://127.0.0.1:8082";
564
+ const timeoutMs = opts.timeoutMs ?? 120_000;
565
+ const requestBody = JSON.stringify(parsed);
566
+
567
+ try {
568
+ const targetUrl = new URL("/v1/chat/completions", bitnetUrl);
569
+ const proxyRes = await new Promise<http.IncomingMessage>((resolve, reject) => {
570
+ const proxyReq = http.request(
571
+ {
572
+ hostname: targetUrl.hostname,
573
+ port: parseInt(targetUrl.port),
574
+ path: targetUrl.pathname,
575
+ method: "POST",
576
+ headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(requestBody) },
577
+ timeout: timeoutMs,
578
+ },
579
+ resolve
580
+ );
581
+ proxyReq.on("error", reject);
582
+ proxyReq.on("timeout", () => { proxyReq.destroy(new Error("BitNet request timed out")); });
583
+ proxyReq.write(requestBody);
584
+ proxyReq.end();
585
+ });
586
+
587
+ // Forward status + headers
588
+ const fwdHeaders: Record<string, string> = { ...corsHeaders() };
589
+ const ct = proxyRes.headers["content-type"];
590
+ if (ct) fwdHeaders["Content-Type"] = ct;
591
+ if (stream) {
592
+ fwdHeaders["Cache-Control"] = "no-cache";
593
+ fwdHeaders["Connection"] = "keep-alive";
594
+ }
595
+ res.writeHead(proxyRes.statusCode ?? 200, fwdHeaders);
596
+ proxyRes.pipe(res);
597
+ } catch (err) {
598
+ const msg = (err as Error).message;
599
+ if (msg.includes("ECONNREFUSED") || msg.includes("ECONNRESET") || msg.includes("ENOTFOUND")) {
600
+ res.writeHead(503, { "Content-Type": "application/json", ...corsHeaders() });
601
+ res.end(JSON.stringify({
602
+ error: {
603
+ message: "BitNet server not running. Start with: sudo systemctl start bitnet-server",
604
+ type: "bitnet_error",
605
+ code: "bitnet_unavailable",
606
+ },
607
+ }));
608
+ } else {
609
+ opts.warn(`[cli-bridge] BitNet error for ${model}: ${msg}`);
610
+ if (!res.headersSent) {
611
+ res.writeHead(500, { "Content-Type": "application/json", ...corsHeaders() });
612
+ res.end(JSON.stringify({ error: { message: msg, type: "bitnet_error" } }));
613
+ }
614
+ }
615
+ }
616
+ return;
617
+ }
618
+ // ─────────────────────────────────────────────────────────────────────────
619
+
552
620
  // ── CLI runner routing (Gemini / Claude Code) ─────────────────────────────
553
621
  let content: string;
554
622
  try {
@@ -0,0 +1,195 @@
1
+ /**
2
+ * test/bitnet-proxy.test.ts
3
+ *
4
+ * Tests for BitNet local inference routing in the cli-bridge proxy.
5
+ * Spins up a mock llama-server and validates:
6
+ * - 503 when BitNet server is unreachable
7
+ * - Successful forward (non-streaming)
8
+ * - Tools rejection (400)
9
+ */
10
+
11
+ import { describe, it, expect, beforeAll, afterAll } from "vitest";
12
+ import http from "node:http";
13
+ import type { AddressInfo } from "node:net";
14
+ import { startProxyServer, CLI_MODELS } from "../src/proxy-server.js";
15
+
16
+ // ──────────────────────────────────────────────────────────────────────────────
17
+ // Mock llama-server — responds to POST /v1/chat/completions
18
+ // ──────────────────────────────────────────────────────────────────────────────
19
+
20
+ let mockLlamaServer: http.Server;
21
+ let mockLlamaPort: number;
22
+
23
+ function startMockLlamaServer(): Promise<void> {
24
+ return new Promise((resolve) => {
25
+ mockLlamaServer = http.createServer((req, res) => {
26
+ if (req.url === "/v1/models" && req.method === "GET") {
27
+ res.writeHead(200, { "Content-Type": "application/json" });
28
+ res.end(JSON.stringify({ data: [{ id: "bitnet-2b" }] }));
29
+ return;
30
+ }
31
+ if (req.url === "/v1/chat/completions" && req.method === "POST") {
32
+ const chunks: Buffer[] = [];
33
+ req.on("data", (d: Buffer) => chunks.push(d));
34
+ req.on("end", () => {
35
+ const body = JSON.parse(Buffer.concat(chunks).toString("utf8"));
36
+ const lastMsg = body.messages?.[body.messages.length - 1]?.content ?? "";
37
+ res.writeHead(200, { "Content-Type": "application/json" });
38
+ res.end(JSON.stringify({
39
+ id: "chatcmpl-bitnet-mock",
40
+ object: "chat.completion",
41
+ created: Math.floor(Date.now() / 1000),
42
+ model: "bitnet-2b",
43
+ choices: [{ index: 0, message: { role: "assistant", content: `bitnet echo: ${lastMsg}` }, finish_reason: "stop" }],
44
+ usage: { prompt_tokens: 4, completion_tokens: 6, total_tokens: 10 },
45
+ }));
46
+ });
47
+ return;
48
+ }
49
+ res.writeHead(404);
50
+ res.end();
51
+ });
52
+ mockLlamaServer.listen(0, "127.0.0.1", () => {
53
+ mockLlamaPort = (mockLlamaServer.address() as AddressInfo).port;
54
+ resolve();
55
+ });
56
+ });
57
+ }
58
+
59
+ // ──────────────────────────────────────────────────────────────────────────────
60
+ // HTTP helpers
61
+ // ──────────────────────────────────────────────────────────────────────────────
62
+
63
+ async function httpPost(
64
+ url: string,
65
+ body: unknown,
66
+ headers: Record<string, string> = {}
67
+ ): Promise<{ status: number; body: unknown }> {
68
+ return new Promise((resolve, reject) => {
69
+ const data = JSON.stringify(body);
70
+ const urlObj = new URL(url);
71
+ const req = http.request(
72
+ {
73
+ hostname: urlObj.hostname,
74
+ port: parseInt(urlObj.port),
75
+ path: urlObj.pathname,
76
+ method: "POST",
77
+ headers: {
78
+ "Content-Type": "application/json",
79
+ "Content-Length": Buffer.byteLength(data),
80
+ ...headers,
81
+ },
82
+ },
83
+ (res) => {
84
+ let resp = "";
85
+ res.on("data", (c) => (resp += c));
86
+ res.on("end", () => {
87
+ try { resolve({ status: res.statusCode ?? 0, body: JSON.parse(resp) }); }
88
+ catch { resolve({ status: res.statusCode ?? 0, body: resp }); }
89
+ });
90
+ }
91
+ );
92
+ req.on("error", reject);
93
+ req.write(data);
94
+ req.end();
95
+ });
96
+ }
97
+
98
+ // ──────────────────────────────────────────────────────────────────────────────
99
+ // Setup: two proxy servers
100
+ // - withBitNet: points to mock llama-server
101
+ // - noBitNet: points to unreachable port (503 expected)
102
+ // ──────────────────────────────────────────────────────────────────────────────
103
+
104
+ const TEST_KEY = "test-bitnet-key";
105
+ let serverWith: http.Server;
106
+ let serverNo: http.Server;
107
+ let urlWith: string;
108
+ let urlNo: string;
109
+
110
+ beforeAll(async () => {
111
+ await startMockLlamaServer();
112
+
113
+ serverWith = await startProxyServer({
114
+ port: 0,
115
+ apiKey: TEST_KEY,
116
+ log: () => {},
117
+ warn: () => {},
118
+ getBitNetServerUrl: () => `http://127.0.0.1:${mockLlamaPort}`,
119
+ });
120
+ const addrWith = serverWith.address() as AddressInfo;
121
+ urlWith = `http://127.0.0.1:${addrWith.port}`;
122
+
123
+ serverNo = await startProxyServer({
124
+ port: 0,
125
+ apiKey: TEST_KEY,
126
+ log: () => {},
127
+ warn: () => {},
128
+ getBitNetServerUrl: () => `http://127.0.0.1:1`, // unreachable
129
+ });
130
+ const addrNo = serverNo.address() as AddressInfo;
131
+ urlNo = `http://127.0.0.1:${addrNo.port}`;
132
+ });
133
+
134
+ afterAll(async () => {
135
+ await new Promise<void>((r) => serverWith.close(() => r()));
136
+ await new Promise<void>((r) => serverNo.close(() => r()));
137
+ await new Promise<void>((r) => mockLlamaServer.close(() => r()));
138
+ });
139
+
140
+ // ──────────────────────────────────────────────────────────────────────────────
141
+ // Tests
142
+ // ──────────────────────────────────────────────────────────────────────────────
143
+
144
+ describe("CLI_MODELS includes BitNet", () => {
145
+ it("has local-bitnet/bitnet-2b in the model list", () => {
146
+ const bitnet = CLI_MODELS.filter((m) => m.id.startsWith("local-bitnet/"));
147
+ expect(bitnet).toHaveLength(1);
148
+ expect(bitnet[0].id).toBe("local-bitnet/bitnet-2b");
149
+ });
150
+ });
151
+
152
+ describe("POST /v1/chat/completions — BitNet routing", () => {
153
+ const auth = { Authorization: `Bearer ${TEST_KEY}` };
154
+
155
+ it("returns 503 when BitNet server is unreachable", async () => {
156
+ const { status, body } = await httpPost(
157
+ `${urlNo}/v1/chat/completions`,
158
+ { model: "local-bitnet/bitnet-2b", messages: [{ role: "user", content: "Hi" }] },
159
+ auth
160
+ );
161
+ expect(status).toBe(503);
162
+ const b = body as { error: { code: string; message: string } };
163
+ expect(b.error.code).toBe("bitnet_unavailable");
164
+ expect(b.error.message).toContain("BitNet server not running");
165
+ });
166
+
167
+ it("forwards request to mock llama-server (non-streaming)", async () => {
168
+ const { status, body } = await httpPost(
169
+ `${urlWith}/v1/chat/completions`,
170
+ { model: "local-bitnet/bitnet-2b", messages: [{ role: "user", content: "Hello BitNet" }], stream: false },
171
+ auth
172
+ );
173
+ expect(status).toBe(200);
174
+ const b = body as {
175
+ choices: Array<{ message: { content: string }; finish_reason: string }>;
176
+ };
177
+ expect(b.choices[0].message.content).toContain("Hello BitNet");
178
+ expect(b.choices[0].finish_reason).toBe("stop");
179
+ });
180
+
181
+ it("rejects tool calls with 400", async () => {
182
+ const { status, body } = await httpPost(
183
+ `${urlWith}/v1/chat/completions`,
184
+ {
185
+ model: "local-bitnet/bitnet-2b",
186
+ messages: [{ role: "user", content: "use tools" }],
187
+ tools: [{ type: "function", function: { name: "test", parameters: {} } }],
188
+ },
189
+ auth
190
+ );
191
+ expect(status).toBe(400);
192
+ const b = body as { error: { code: string } };
193
+ expect(b.error.code).toBe("tools_not_supported");
194
+ });
195
+ });