@elvatis_com/openclaw-cli-bridge-elvatis 1.9.1 → 2.1.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.
@@ -12,10 +12,54 @@
12
12
  * routeToCliRunner is mocked so we don't need real CLIs installed.
13
13
  */
14
14
 
15
- import { describe, it, expect, beforeAll, afterAll, vi } from "vitest";
15
+ import { describe, it, expect, beforeAll, afterAll, vi, beforeEach } from "vitest";
16
16
  import http from "node:http";
17
17
  import { startProxyServer, CLI_MODELS } from "../src/proxy-server.js";
18
18
 
19
+ // Mock session-manager so we don't spawn real CLIs for session endpoints
20
+ const mockSessions = new Map<string, { model: string; status: string; stdout: string; stderr: string; exitCode: number | null; startTime: number }>();
21
+ let nextSessionId = "aabbccdd11223344";
22
+
23
+ vi.mock("../src/session-manager.js", () => ({
24
+ sessionManager: {
25
+ spawn: vi.fn((model: string, _messages: unknown[]) => {
26
+ const id = nextSessionId;
27
+ mockSessions.set(id, { model, status: "running", stdout: "", stderr: "", exitCode: null, startTime: Date.now() });
28
+ // Generate a different ID next time
29
+ nextSessionId = Math.random().toString(16).slice(2, 18).padEnd(16, "0");
30
+ return id;
31
+ }),
32
+ poll: vi.fn((sessionId: string) => {
33
+ const entry = mockSessions.get(sessionId);
34
+ if (!entry) return null;
35
+ return { running: entry.status === "running", exitCode: entry.exitCode, status: entry.status };
36
+ }),
37
+ log: vi.fn((sessionId: string, offset = 0) => {
38
+ const entry = mockSessions.get(sessionId);
39
+ if (!entry) return null;
40
+ return { stdout: entry.stdout.slice(offset), stderr: entry.stderr.slice(offset), offset: entry.stdout.length };
41
+ }),
42
+ write: vi.fn((sessionId: string) => {
43
+ return mockSessions.has(sessionId);
44
+ }),
45
+ kill: vi.fn((sessionId: string) => {
46
+ const entry = mockSessions.get(sessionId);
47
+ if (!entry || entry.status !== "running") return false;
48
+ entry.status = "killed";
49
+ return true;
50
+ }),
51
+ list: vi.fn(() => {
52
+ const result: { sessionId: string; model: string; status: string; startTime: number; exitCode: number | null }[] = [];
53
+ for (const [sessionId, entry] of mockSessions) {
54
+ result.push({ sessionId, model: entry.model, status: entry.status, startTime: entry.startTime, exitCode: entry.exitCode });
55
+ }
56
+ return result;
57
+ }),
58
+ stop: vi.fn(),
59
+ cleanup: vi.fn(),
60
+ },
61
+ }));
62
+
19
63
  // Mock cli-runner so we don't spawn real CLIs
20
64
  vi.mock("../src/cli-runner.js", async (importOriginal) => {
21
65
  const orig = await importOriginal<typeof import("../src/cli-runner.js")>();
@@ -24,7 +68,7 @@ vi.mock("../src/cli-runner.js", async (importOriginal) => {
24
68
  routeToCliRunner: vi.fn(async (model: string, _messages: unknown[], _timeout: number) => {
25
69
  // Simulate the real router: strip vllm/ prefix, validate model
26
70
  const normalized = model.startsWith("vllm/") ? model.slice(5) : model;
27
- if (!normalized.startsWith("cli-gemini/") && !normalized.startsWith("cli-claude/")) {
71
+ if (!normalized.startsWith("cli-gemini/") && !normalized.startsWith("cli-claude/") && !normalized.startsWith("openai-codex/") && !normalized.startsWith("opencode/") && !normalized.startsWith("pi/")) {
28
72
  throw new Error(`Unknown CLI bridge model: "${model}"`);
29
73
  }
30
74
  return `Mock response from ${normalized}`;
@@ -474,4 +518,232 @@ describe("Model capabilities", () => {
474
518
  expect(m.capabilities.tools).toBe(true);
475
519
  }
476
520
  });
521
+
522
+ it("openai-codex models have capabilities.tools===false", async () => {
523
+ const res = await fetch("/v1/models");
524
+ const body = JSON.parse(res.body);
525
+ const codexModels = body.data.filter((m: { id: string }) => m.id.startsWith("openai-codex/"));
526
+ expect(codexModels.length).toBeGreaterThan(0);
527
+ for (const m of codexModels) {
528
+ expect(m.capabilities.tools).toBe(false);
529
+ }
530
+ });
531
+
532
+ it("opencode models have capabilities.tools===false", async () => {
533
+ const res = await fetch("/v1/models");
534
+ const body = JSON.parse(res.body);
535
+ const ocModels = body.data.filter((m: { id: string }) => m.id.startsWith("opencode/"));
536
+ expect(ocModels.length).toBeGreaterThan(0);
537
+ for (const m of ocModels) {
538
+ expect(m.capabilities.tools).toBe(false);
539
+ }
540
+ });
541
+
542
+ it("pi models have capabilities.tools===false", async () => {
543
+ const res = await fetch("/v1/models");
544
+ const body = JSON.parse(res.body);
545
+ const piModels = body.data.filter((m: { id: string }) => m.id.startsWith("pi/"));
546
+ expect(piModels.length).toBeGreaterThan(0);
547
+ for (const m of piModels) {
548
+ expect(m.capabilities.tools).toBe(false);
549
+ }
550
+ });
551
+ });
552
+
553
+ // ──────────────────────────────────────────────────────────────────────────────
554
+ // Chat completions — new model prefixes (codex, opencode, pi)
555
+ // ──────────────────────────────────────────────────────────────────────────────
556
+
557
+ describe("POST /v1/chat/completions — new model prefixes", () => {
558
+ it("returns completion for openai-codex model", async () => {
559
+ const res = await json("/v1/chat/completions", {
560
+ model: "openai-codex/gpt-5.3-codex",
561
+ messages: [{ role: "user", content: "hello" }],
562
+ });
563
+ expect(res.status).toBe(200);
564
+ const body = JSON.parse(res.body);
565
+ expect(body.choices[0].message.content).toBe("Mock response from openai-codex/gpt-5.3-codex");
566
+ });
567
+
568
+ it("returns completion for opencode model", async () => {
569
+ const res = await json("/v1/chat/completions", {
570
+ model: "opencode/default",
571
+ messages: [{ role: "user", content: "hello" }],
572
+ });
573
+ expect(res.status).toBe(200);
574
+ const body = JSON.parse(res.body);
575
+ expect(body.choices[0].message.content).toBe("Mock response from opencode/default");
576
+ });
577
+
578
+ it("returns completion for pi model", async () => {
579
+ const res = await json("/v1/chat/completions", {
580
+ model: "pi/default",
581
+ messages: [{ role: "user", content: "hello" }],
582
+ });
583
+ expect(res.status).toBe(200);
584
+ const body = JSON.parse(res.body);
585
+ expect(body.choices[0].message.content).toBe("Mock response from pi/default");
586
+ });
587
+
588
+ it("rejects tools for openai-codex models", async () => {
589
+ const res = await json("/v1/chat/completions", {
590
+ model: "openai-codex/gpt-5.3-codex",
591
+ messages: [{ role: "user", content: "hi" }],
592
+ tools: [{ type: "function", function: { name: "test", parameters: {} } }],
593
+ });
594
+ expect(res.status).toBe(400);
595
+ expect(JSON.parse(res.body).error.code).toBe("tools_not_supported");
596
+ });
597
+
598
+ it("rejects tools for opencode models", async () => {
599
+ const res = await json("/v1/chat/completions", {
600
+ model: "opencode/default",
601
+ messages: [{ role: "user", content: "hi" }],
602
+ tools: [{ type: "function", function: { name: "test", parameters: {} } }],
603
+ });
604
+ expect(res.status).toBe(400);
605
+ expect(JSON.parse(res.body).error.code).toBe("tools_not_supported");
606
+ });
607
+
608
+ it("rejects tools for pi models", async () => {
609
+ const res = await json("/v1/chat/completions", {
610
+ model: "pi/default",
611
+ messages: [{ role: "user", content: "hi" }],
612
+ tools: [{ type: "function", function: { name: "test", parameters: {} } }],
613
+ });
614
+ expect(res.status).toBe(400);
615
+ expect(JSON.parse(res.body).error.code).toBe("tools_not_supported");
616
+ });
617
+ });
618
+
619
+ // ──────────────────────────────────────────────────────────────────────────────
620
+ // Session Manager endpoints
621
+ // ──────────────────────────────────────────────────────────────────────────────
622
+
623
+ describe("Session Manager endpoints", () => {
624
+ beforeEach(() => {
625
+ mockSessions.clear();
626
+ nextSessionId = "aabbccdd11223344";
627
+ });
628
+
629
+ it("POST /v1/sessions/spawn returns sessionId", async () => {
630
+ const res = await json("/v1/sessions/spawn", {
631
+ model: "cli-gemini/gemini-2.5-pro",
632
+ messages: [{ role: "user", content: "hello" }],
633
+ });
634
+ expect(res.status).toBe(200);
635
+ const body = JSON.parse(res.body);
636
+ expect(body.sessionId).toBe("aabbccdd11223344");
637
+ });
638
+
639
+ it("POST /v1/sessions/spawn rejects missing model", async () => {
640
+ const res = await json("/v1/sessions/spawn", {
641
+ messages: [{ role: "user", content: "hello" }],
642
+ });
643
+ expect(res.status).toBe(400);
644
+ expect(JSON.parse(res.body).error.message).toContain("model and messages are required");
645
+ });
646
+
647
+ it("POST /v1/sessions/spawn rejects missing messages", async () => {
648
+ const res = await json("/v1/sessions/spawn", {
649
+ model: "cli-gemini/gemini-2.5-pro",
650
+ messages: [],
651
+ });
652
+ expect(res.status).toBe(400);
653
+ });
654
+
655
+ it("GET /v1/sessions lists sessions", async () => {
656
+ // Spawn one session first
657
+ await json("/v1/sessions/spawn", {
658
+ model: "cli-gemini/gemini-2.5-pro",
659
+ messages: [{ role: "user", content: "hello" }],
660
+ });
661
+
662
+ const res = await fetch("/v1/sessions");
663
+ expect(res.status).toBe(200);
664
+ const body = JSON.parse(res.body);
665
+ expect(body.sessions).toHaveLength(1);
666
+ expect(body.sessions[0].model).toBe("cli-gemini/gemini-2.5-pro");
667
+ expect(body.sessions[0].status).toBe("running");
668
+ });
669
+
670
+ it("GET /v1/sessions/:id/poll returns status", async () => {
671
+ const spawnRes = await json("/v1/sessions/spawn", {
672
+ model: "cli-gemini/gemini-2.5-pro",
673
+ messages: [{ role: "user", content: "hello" }],
674
+ });
675
+ const { sessionId } = JSON.parse(spawnRes.body);
676
+
677
+ const res = await fetch(`/v1/sessions/${sessionId}/poll`);
678
+ expect(res.status).toBe(200);
679
+ const body = JSON.parse(res.body);
680
+ expect(body.running).toBe(true);
681
+ expect(body.status).toBe("running");
682
+ });
683
+
684
+ it("GET /v1/sessions/:id/poll returns 404 for unknown session", async () => {
685
+ const res = await fetch("/v1/sessions/0000000000000000/poll");
686
+ expect(res.status).toBe(404);
687
+ });
688
+
689
+ it("GET /v1/sessions/:id/log returns output", async () => {
690
+ const spawnRes = await json("/v1/sessions/spawn", {
691
+ model: "cli-gemini/gemini-2.5-pro",
692
+ messages: [{ role: "user", content: "hello" }],
693
+ });
694
+ const { sessionId } = JSON.parse(spawnRes.body);
695
+
696
+ const res = await fetch(`/v1/sessions/${sessionId}/log`);
697
+ expect(res.status).toBe(200);
698
+ const body = JSON.parse(res.body);
699
+ expect(typeof body.stdout).toBe("string");
700
+ expect(typeof body.stderr).toBe("string");
701
+ expect(typeof body.offset).toBe("number");
702
+ });
703
+
704
+ it("GET /v1/sessions/:id/log returns 404 for unknown session", async () => {
705
+ const res = await fetch("/v1/sessions/0000000000000000/log");
706
+ expect(res.status).toBe(404);
707
+ });
708
+
709
+ it("POST /v1/sessions/:id/write sends data", async () => {
710
+ const spawnRes = await json("/v1/sessions/spawn", {
711
+ model: "cli-gemini/gemini-2.5-pro",
712
+ messages: [{ role: "user", content: "hello" }],
713
+ });
714
+ const { sessionId } = JSON.parse(spawnRes.body);
715
+
716
+ const res = await json(`/v1/sessions/${sessionId}/write`, { data: "input" });
717
+ expect(res.status).toBe(200);
718
+ const body = JSON.parse(res.body);
719
+ expect(body.ok).toBe(true);
720
+ });
721
+
722
+ it("POST /v1/sessions/:id/kill terminates session", async () => {
723
+ const spawnRes = await json("/v1/sessions/spawn", {
724
+ model: "cli-gemini/gemini-2.5-pro",
725
+ messages: [{ role: "user", content: "hello" }],
726
+ });
727
+ const { sessionId } = JSON.parse(spawnRes.body);
728
+
729
+ const res = await json(`/v1/sessions/${sessionId}/kill`, {});
730
+ expect(res.status).toBe(200);
731
+ const body = JSON.parse(res.body);
732
+ expect(body.ok).toBe(true);
733
+ });
734
+
735
+ it("POST /v1/sessions/:id/kill returns false for already-killed session", async () => {
736
+ const spawnRes = await json("/v1/sessions/spawn", {
737
+ model: "cli-gemini/gemini-2.5-pro",
738
+ messages: [{ role: "user", content: "hello" }],
739
+ });
740
+ const { sessionId } = JSON.parse(spawnRes.body);
741
+
742
+ // Kill once
743
+ await json(`/v1/sessions/${sessionId}/kill`, {});
744
+ // Kill again
745
+ const res = await json(`/v1/sessions/${sessionId}/kill`, {});
746
+ expect(res.status).toBe(404);
747
+ expect(JSON.parse(res.body).ok).toBe(false);
748
+ });
477
749
  });