@elvatis_com/openclaw-cli-bridge-elvatis 0.2.14 → 0.2.16

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.
@@ -2,34 +2,152 @@
2
2
  "aahp_version": "3.0",
3
3
  "project": "openclaw-cli-bridge-elvatis",
4
4
  "last_session": {
5
- "agent": "openai-codex/gpt-5.3-codex",
5
+ "agent": "claude-code",
6
6
  "session_id": "main",
7
- "timestamp": "2026-03-07T21:29:00Z",
8
- "commit": "16fca7c",
7
+ "timestamp": "2026-03-08T08:08:44.175Z",
8
+ "commit": "30be8ae",
9
9
  "phase": "integration",
10
- "duration_minutes": 60
10
+ "duration_minutes": 2
11
11
  },
12
12
  "files": {
13
- "STATUS.md": { "checksum": "sha256:tbd", "updated": "2026-03-07T21:29:00Z", "lines": 0, "summary": "Auth bridge done; request bridge implemented; pending runtime validation." },
14
- "NEXT_ACTIONS.md": { "checksum": "sha256:tbd", "updated": "2026-03-07T21:29:00Z", "lines": 0, "summary": "Top priority: validate proxy + vllm model calls, then release pipeline." },
15
- "LOG.md": { "checksum": "sha256:tbd", "updated": "2026-03-07T21:29:00Z", "lines": 0, "summary": "Repo created, provider fixed, proxy architecture added." },
16
- "DASHBOARD.md": { "checksum": "sha256:tbd", "updated": "2026-03-07T21:29:00Z", "lines": 0, "summary": "2 bridge phases implemented; validation pending." },
17
- "TRUST.md": { "checksum": "sha256:tbd", "updated": "2026-03-07T21:29:00Z", "lines": 0, "summary": "No secret output, local-only proxy, explicit scope constraints." },
18
- "CONVENTIONS.md": { "checksum": "sha256:tbd", "updated": "2026-03-07T21:29:00Z", "lines": 0, "summary": "TypeScript strict + AAHP v3 + multi-platform release discipline." },
19
- "WORKFLOW.md": { "checksum": "sha256:tbd", "updated": "2026-03-07T21:29:00Z", "lines": 0, "summary": "Validate → harden → publish (GitHub, npm, ClawHub)." }
13
+ "STATUS.md": {
14
+ "checksum": "sha256:tbd",
15
+ "updated": "2026-03-07T21:29:00Z",
16
+ "lines": 0,
17
+ "summary": "Auth bridge done; request bridge implemented; pending runtime validation."
18
+ },
19
+ "NEXT_ACTIONS.md": {
20
+ "checksum": "sha256:tbd",
21
+ "updated": "2026-03-07T21:29:00Z",
22
+ "lines": 0,
23
+ "summary": "Top priority: validate proxy + vllm model calls, then release pipeline."
24
+ },
25
+ "LOG.md": {
26
+ "checksum": "sha256:tbd",
27
+ "updated": "2026-03-07T21:29:00Z",
28
+ "lines": 0,
29
+ "summary": "Repo created, provider fixed, proxy architecture added."
30
+ },
31
+ "DASHBOARD.md": {
32
+ "checksum": "sha256:tbd",
33
+ "updated": "2026-03-07T21:29:00Z",
34
+ "lines": 0,
35
+ "summary": "2 bridge phases implemented; validation pending."
36
+ },
37
+ "TRUST.md": {
38
+ "checksum": "sha256:tbd",
39
+ "updated": "2026-03-07T21:29:00Z",
40
+ "lines": 0,
41
+ "summary": "No secret output, local-only proxy, explicit scope constraints."
42
+ },
43
+ "CONVENTIONS.md": {
44
+ "checksum": "sha256:tbd",
45
+ "updated": "2026-03-07T21:29:00Z",
46
+ "lines": 0,
47
+ "summary": "TypeScript strict + AAHP v3 + multi-platform release discipline."
48
+ },
49
+ "WORKFLOW.md": {
50
+ "checksum": "sha256:tbd",
51
+ "updated": "2026-03-07T21:29:00Z",
52
+ "lines": 0,
53
+ "summary": "Validate → harden → publish (GitHub, npm, ClawHub)."
54
+ }
55
+ },
56
+ "quick_context": "All providers validated end-to-end. 28 automated tests (unit + e2e proxy) pass. Proxy endpoints, vllm/ prefix stripping, auth, streaming, error handling all verified. Next: publish to npm + ClawHub.",
57
+ "token_budget": {
58
+ "manifest_only": 90,
59
+ "manifest_plus_core": 350,
60
+ "full_read": 700
20
61
  },
21
- "quick_context": "Codex auth bridge is working and repo is live. gpt-5.4 fails due to OpenAI scope limitations, so development is on gpt-5.3-codex. Request-bridge code for Gemini/Claude via vllm-compatible proxy exists; next is end-to-end validation.",
22
- "token_budget": { "manifest_only": 90, "manifest_plus_core": 350, "full_read": 700 },
23
62
  "next_task_id": 9,
24
63
  "tasks": {
25
- "T-001": { "title": "Scaffold plugin structure + AAHP handoff", "status": "done", "priority": "high", "depends_on": [], "created": "2026-03-07T20:40:00Z", "completed": "2026-03-07T20:56:00Z" },
26
- "T-002": { "title": "Implement openai-codex provider (Codex CLI auth bridge)", "status": "done", "priority": "high", "depends_on": ["T-001"], "created": "2026-03-07T20:40:00Z", "completed": "2026-03-07T20:56:00Z" },
27
- "T-003": { "title": "Test auth flow: openclaw models auth login --provider openai-codex", "status": "done", "priority": "high", "depends_on": ["T-002"], "created": "2026-03-07T20:56:00Z", "completed": "2026-03-07T21:01:00Z" },
28
- "T-004": { "title": "Verify model call: test gpt-5.2 or gpt-5.3-codex responds", "status": "done", "priority": "high", "depends_on": ["T-003"], "created": "2026-03-07T20:56:00Z", "completed": "2026-03-07T21:27:00Z" },
29
- "T-005": { "title": "Implement Gemini CLI request bridge", "status": "done", "priority": "medium", "depends_on": ["T-003"], "created": "2026-03-07T20:56:00Z", "completed": "2026-03-07T21:23:00Z" },
30
- "T-006": { "title": "Implement Claude Code CLI request bridge", "status": "done", "priority": "medium", "depends_on": ["T-003"], "created": "2026-03-07T20:56:00Z", "completed": "2026-03-07T21:23:00Z" },
31
- "T-007": { "title": "Create GitHub repo and push initial code", "status": "done", "priority": "high", "depends_on": ["T-004"], "created": "2026-03-07T21:20:00Z", "completed": "2026-03-07T21:24:00Z" },
32
- "T-008": { "title": "Validate proxy endpoints + vllm model calls end-to-end", "status": "ready", "priority": "high", "depends_on": ["T-005", "T-006"], "created": "2026-03-07T21:29:00Z" },
33
- "T-009": { "title": "Publish to npm + ClawHub", "status": "blocked", "priority": "medium", "depends_on": ["T-008"], "created": "2026-03-07T21:29:00Z" }
64
+ "T-001": {
65
+ "title": "Scaffold plugin structure + AAHP handoff",
66
+ "status": "done",
67
+ "priority": "high",
68
+ "depends_on": [],
69
+ "created": "2026-03-07T20:40:00Z",
70
+ "completed": "2026-03-07T20:56:00Z"
71
+ },
72
+ "T-002": {
73
+ "title": "Implement openai-codex provider (Codex CLI auth bridge)",
74
+ "status": "done",
75
+ "priority": "high",
76
+ "depends_on": [
77
+ "T-001"
78
+ ],
79
+ "created": "2026-03-07T20:40:00Z",
80
+ "completed": "2026-03-07T20:56:00Z"
81
+ },
82
+ "T-003": {
83
+ "title": "Test auth flow: openclaw models auth login --provider openai-codex",
84
+ "status": "done",
85
+ "priority": "high",
86
+ "depends_on": [
87
+ "T-002"
88
+ ],
89
+ "created": "2026-03-07T20:56:00Z",
90
+ "completed": "2026-03-07T21:01:00Z"
91
+ },
92
+ "T-004": {
93
+ "title": "Verify model call: test gpt-5.2 or gpt-5.3-codex responds",
94
+ "status": "done",
95
+ "priority": "high",
96
+ "depends_on": [
97
+ "T-003"
98
+ ],
99
+ "created": "2026-03-07T20:56:00Z",
100
+ "completed": "2026-03-07T21:27:00Z"
101
+ },
102
+ "T-005": {
103
+ "title": "Implement Gemini CLI request bridge",
104
+ "status": "done",
105
+ "priority": "medium",
106
+ "depends_on": [
107
+ "T-003"
108
+ ],
109
+ "created": "2026-03-07T20:56:00Z",
110
+ "completed": "2026-03-07T21:23:00Z"
111
+ },
112
+ "T-006": {
113
+ "title": "Implement Claude Code CLI request bridge",
114
+ "status": "done",
115
+ "priority": "medium",
116
+ "depends_on": [
117
+ "T-003"
118
+ ],
119
+ "created": "2026-03-07T20:56:00Z",
120
+ "completed": "2026-03-07T21:23:00Z"
121
+ },
122
+ "T-007": {
123
+ "title": "Create GitHub repo and push initial code",
124
+ "status": "done",
125
+ "priority": "high",
126
+ "depends_on": [
127
+ "T-004"
128
+ ],
129
+ "created": "2026-03-07T21:20:00Z",
130
+ "completed": "2026-03-07T21:24:00Z"
131
+ },
132
+ "T-008": {
133
+ "title": "Validate proxy endpoints + vllm model calls end-to-end",
134
+ "status": "done",
135
+ "priority": "high",
136
+ "depends_on": [
137
+ "T-005",
138
+ "T-006"
139
+ ],
140
+ "created": "2026-03-07T21:29:00Z",
141
+ "completed": "2026-03-08T08:08:44.175Z"
142
+ },
143
+ "T-009": {
144
+ "title": "Publish to npm + ClawHub",
145
+ "status": "ready",
146
+ "priority": "medium",
147
+ "depends_on": [
148
+ "T-008"
149
+ ],
150
+ "created": "2026-03-07T21:29:00Z"
151
+ }
34
152
  }
35
153
  }
@@ -1,42 +1,49 @@
1
1
  # NEXT_ACTIONS.md — openclaw-cli-bridge-elvatis
2
2
 
3
- _Last updated: 2026-03-07_
3
+ _Last updated: 2026-03-08_
4
4
 
5
5
  ## Status Summary
6
6
 
7
- | Status | Count |
8
- |--------|-------|
9
- | Done | 6 |
10
- | Ready | 3 |
11
- | Blocked | 0 |
7
+ | Status | Count |
8
+ |---------|-------|
9
+ | Done | 8 |
10
+ | Ready | 1 |
11
+ | Blocked | 0 |
12
12
 
13
13
  ---
14
14
 
15
- ## Ready — Work These Next
15
+ ## Ready — Work These Next
16
16
 
17
- ### T-101: [medium] — Unit tests for prompt formatter + model router
18
- - **Goal:** Cover `src/cli-runner.ts` message formatting and model routing logic with vitest tests.
19
- - **Files:** `test/cli-runner.test.ts`, `src/cli-runner.ts`
20
- - **Definition of done:** Prompt truncation, stdin format, and model→CLI mapping covered by tests.
17
+ ### T-009: [medium] — Publish to npm + ClawHub
21
18
 
22
- ### T-102: [low] Proxy auth key rotation via config
23
- - **Goal:** Allow `proxyApiKey` to be rotated without code change via config reload.
24
- - **Files:** `index.ts`, `src/proxy-server.ts`
19
+ - **Goal:** Publish the next release to all distribution channels (GitHub, npm, ClawHub).
20
+ - **Context:** All providers are validated end-to-end. 28 automated tests pass (unit + e2e proxy). The codebase is stable at v0.2.15. If changes were made since the last publish, bump the version first.
21
+ - **What to do:**
22
+ 1. Check if any code changes since v0.2.15 warrant a version bump
23
+ 2. If bumping: update version in `package.json`, `openclaw.plugin.json`, `README.md`, `SKILL.md`, `STATUS.md` (see CONVENTIONS.md release checklist)
24
+ 3. Run `npm run typecheck && npm test` — must pass
25
+ 4. `git tag vX.Y.Z && git push origin main && git push origin vX.Y.Z`
26
+ 5. `gh release create vX.Y.Z --title "..." --notes "..."`
27
+ 6. `npm publish --access public`
28
+ 7. ClawHub publish via rsync workaround (see CONVENTIONS.md)
29
+ 8. Update all handoff docs (STATUS.md, DASHBOARD.md, LOG.md, NEXT_ACTIONS.md, README.md, SKILL.md)
30
+ - **Files:** `package.json`, `openclaw.plugin.json`, `README.md`, `SKILL.md`, `.ai/handoff/STATUS.md`, `.ai/handoff/CONVENTIONS.md`
31
+ - **Definition of done:** Package published on npm + ClawHub at matching version. GitHub release created. All docs updated.
25
32
 
26
- ### T-103: [low] — Explicit model allowlist for CLI execution
27
- - **Goal:** Config-driven allowlist of which model IDs are permitted to spawn CLI subprocesses.
28
- - **Files:** `index.ts`, `src/cli-runner.ts`
33
+ ---
34
+
35
+ ## 🚫 Blocked
36
+
37
+ _No blocked tasks._
29
38
 
30
39
  ---
31
40
 
32
- ## Recently Completed
33
-
34
- | Task | Title | Date |
35
- |------|-------|------|
36
- | T-007 | Critical: remove fuser -k, safe proxy reuse via health probe | 2026-03-08 |
37
- | T-006 | Fix port leak: registerService stop() hook + closeAllConnections | 2026-03-07 |
38
- | T-005 | Add openclaw.extensions to package.json | 2026-03-07 |
39
- | T-004 | /cli-codex + /cli-codex-mini | 2026-03-07 |
40
- | T-003 | /cli-back + /cli-test | 2026-03-07 |
41
- | T-002 | /cli-* model switch commands | 2026-03-07 |
42
- | T-001 | Phase 1+2: auth + proxy + config patcher | 2026-03-07 |
41
+ ## Recently Completed
42
+
43
+ | Task | Title | Date |
44
+ |-------|--------------------------------------------------------------|------------|
45
+ | T-008 | Validate proxy endpoints + vllm model calls end-to-end | 2026-03-08 |
46
+ | T-007 | Create GitHub repo and push initial code | 2026-03-07 |
47
+ | T-006 | Implement Claude Code CLI request bridge | 2026-03-07 |
48
+ | T-005 | Implement Gemini CLI request bridge | 2026-03-07 |
49
+ | T-004 | Verify model call: test gpt-5.2 or gpt-5.3-codex responds | 2026-03-07 |
@@ -1,42 +1,49 @@
1
1
  # STATUS.md — openclaw-cli-bridge-elvatis
2
2
 
3
- _Last updated: 2026-03-07 by Akido (claude-sonnet-4-6)_
3
+ _Last updated: 2026-03-08 by Akido (claude-sonnet-4-6)_
4
4
 
5
- ## Current Version: 0.2.14 — STABLE
5
+ ## Current Version: 0.2.16 — STABLE
6
6
 
7
7
  ## What is done
8
8
 
9
9
  - ✅ Repo: `https://github.com/elvatis/openclaw-cli-bridge-elvatis`
10
- - ✅ npm: `@elvatis_com/openclaw-cli-bridge-elvatis@0.2.14`
11
- - ✅ ClawHub: `openclaw-cli-bridge-elvatis@0.2.14`
10
+ - ✅ npm: `@elvatis_com/openclaw-cli-bridge-elvatis@0.2.16`
11
+ - ✅ ClawHub: `openclaw-cli-bridge-elvatis@0.2.16`
12
12
  - ✅ Phase 1: `openai-codex` provider via `~/.codex/auth.json` (no re-login)
13
13
  - ✅ Phase 2: Local OpenAI-compatible proxy on `127.0.0.1:31337` (Gemini + Claude CLI)
14
14
  - ✅ Phase 3: 10 slash commands (`/cli-sonnet`, `/cli-opus`, `/cli-haiku`, `/cli-gemini`, `/cli-gemini-flash`, `/cli-gemini3`, `/cli-codex`, `/cli-codex-mini`, `/cli-back`, `/cli-test`)
15
15
  - ✅ Config patcher: auto-adds vllm provider to `openclaw.json` on first startup
16
16
  - ✅ Prompt delivery via stdin (no E2BIG, no Gemini agentic mode)
17
- - ✅ `registerService` stop() hook: closes proxy server on plugin teardown (fixes EADDRINUSE on hot-reload)
18
- - ✅ `openclaw.extensions` added to `package.json` (required for `openclaw plugins install`)
17
+ - ✅ `registerService` stop() hook: closes proxy server on plugin teardown
18
+ - ✅ `requireAuth: false` on all commands webchat + WhatsApp authorized via gateway `commands.allowFrom`
19
+ - ✅ `vllm/` prefix stripping in `routeToCliRunner` — accepts both `vllm/cli-claude/...` and bare `cli-claude/...`
20
+ - ✅ End-to-end tested (2026-03-08): claude-sonnet-4-6 ✅ claude-haiku-4-5 ✅ gemini-2.5-flash ✅ gemini-2.5-pro ✅ codex ✅
21
+
22
+ ## Known Operational Notes
23
+
24
+ - **Claude CLI auth expires** — token lifetime ~90 days. When `/cli-test` returns 401, run `claude auth login` on the server to refresh.
25
+ - Config patcher writes `openclaw.json` directly → triggers one gateway restart on first install (expected, one-time only)
26
+ - ClawHub publish ignores `.clawhubignore` — use rsync workaround (see CONVENTIONS.md)
19
27
 
20
28
  ## Bugs Fixed
21
29
 
30
+ ### v0.2.14 — vllm/ prefix not stripped in model router
31
+ `routeToCliRunner` received full provider path `vllm/cli-claude/...` from OpenClaw
32
+ but only checked for `cli-claude/...` — caused "Unknown CLI bridge model" on all requests.
33
+ Fixed by stripping the `vllm/` prefix before routing.
34
+
35
+ ### v0.2.13 — requireAuth blocking webchat commands
36
+ All `/cli-*` commands had `requireAuth: true`. Plugin-level auth checks `isAuthorizedSender`
37
+ via a different resolution path than `commands.allowFrom` config — webchat senders were
38
+ never authorized. Fixed by setting `requireAuth: false`; gateway-level `commands.allowFrom`
39
+ is the correct security layer.
40
+
22
41
  ### v0.2.9 — Critical: Gateway SIGKILL via fuser
23
42
  `fuser -k 31337/tcp` was sending SIGKILL to the gateway process itself during
24
- in-process hot-reloads. The gateway holds port 31337 (via the proxy it spawned),
25
- so `fuser` found it and killed it — explaining `status=9/KILL` in systemd journal.
26
- Fixed by replacing `fuser -k` with a safe health probe (`GET /v1/models`): if the
27
- existing proxy responds, reuse it silently. If EADDRINUSE but no response, wait 1s
28
- and retry once. No process killing involved.
43
+ in-process hot-reloads. Fixed by replacing `fuser -k` with a safe health probe.
29
44
 
30
- ### v0.2.7–v0.2.8 — EADDRINUSE on hot-reload (partially fixed, superseded by v0.2.9)
31
- Added `closeAllConnections()` + `registerService` stop() hook. Port still leaked
32
- during systemd restarts due to race condition. v0.2.9 health-probe approach is the
33
- definitive fix.
45
+ ### v0.2.7–v0.2.8 — EADDRINUSE on hot-reload
46
+ Added `closeAllConnections()` + `registerService` stop() hook.
34
47
 
35
48
  ### v0.2.6 — Port leak on gateway hot-reload
36
- HTTP proxy server had no cleanup handler. Fixed with `registerService` stop() callback.
37
-
38
- ## Open Risks
39
-
40
- - `openai-codex/gpt-5.4` returns 401 missing scope `model.request` — external (OpenAI account scope), not plugin code
41
- - Config patcher writes `openclaw.json` directly → triggers one gateway restart on first install (expected, one-time only)
42
- - ClawHub publish ignores `.clawhubignore` — use rsync workaround (see CONVENTIONS.md)
49
+ HTTP proxy server had no cleanup handler.
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, and health testing.
4
4
 
5
- **Current version:** `0.2.14`
5
+ **Current version:** `0.2.16`
6
6
 
7
7
  ---
8
8
 
@@ -234,8 +234,24 @@ npm test # vitest run (5 unit tests for formatPrompt)
234
234
 
235
235
  ## Changelog
236
236
 
237
+ ### v0.2.16
238
+ - **feat(T-101):** Expand test suite to 45 tests — new cases for `formatPrompt` (mixed roles, boundary values, system messages) and `routeToCliRunner` (gemini paths, edge cases)
239
+ - **feat(T-103):** Add `DEFAULT_ALLOWED_CLI_MODELS` allowlist; `routeToCliRunner` now rejects unregistered models by default; pass `allowedModels: null` to opt out
240
+
241
+ ### v0.2.15
242
+ - **docs:** Rewrite changelog (entries for v0.2.12–v0.2.14 were corrupted by repeated sed version bumps); all providers verified working (Claude, Gemini, Codex)
243
+ - **docs:** Update STATUS.md with end-to-end test results
244
+
237
245
  ### v0.2.14
238
- - **docs:** Fix changelog (v0.2.10 entry was lost by sed, v0.2.11 description was wrong); enforce single-commit publish discipline
246
+ - **fix:** Strip `vllm/` prefix in `routeToCliRunner` OpenClaw sends full provider path (`vllm/cli-claude/...`) but proxy router expected bare `cli-claude/...`; caused "Unknown CLI bridge model" on all requests
247
+ - **test:** Add 4 routing tests covering both prefixed and non-prefixed model paths (9 tests total)
248
+
249
+ ### v0.2.13
250
+ - **fix:** Set `requireAuth: false` on all `/cli-*` commands — webchat senders were always blocked because plugin-level auth uses a different resolution path than `commands.allowFrom` config; gateway-level allowlist is the correct security layer
251
+ - **fix:** Hardcoded `version: "0.2.5"` in plugin object (`index.ts`) — now tracks `package.json`
252
+
253
+ ### v0.2.12
254
+ - **docs:** Fix changelog continuity — v0.2.10 entry was lost, v0.2.11 description was wrong; all entries now accurate
239
255
 
240
256
  ### v0.2.11
241
257
  - **docs:** Fix README `Current version` header (was stuck at 0.2.9 after 0.2.10 bump)
package/SKILL.md CHANGED
@@ -53,4 +53,4 @@ Each command runs `openclaw models set <model>` atomically and replies with a co
53
53
 
54
54
  See `README.md` for full configuration reference and architecture diagram.
55
55
 
56
- **Version:** 0.2.14
56
+ **Version:** 0.2.16
package/index.ts CHANGED
@@ -277,7 +277,7 @@ function proxyTestRequest(
277
277
  const plugin = {
278
278
  id: "openclaw-cli-bridge-elvatis",
279
279
  name: "OpenClaw CLI Bridge",
280
- version: "0.2.14",
280
+ version: "0.2.15",
281
281
  description:
282
282
  "Phase 1: openai-codex auth bridge. " +
283
283
  "Phase 2: HTTP proxy for gemini/claude CLIs. " +
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "openclaw-cli-bridge-elvatis",
3
3
  "name": "OpenClaw CLI Bridge",
4
- "version": "0.2.14",
4
+ "version": "0.2.16",
5
5
  "description": "Phase 1: openai-codex auth bridge. Phase 2: local HTTP proxy routing model calls through gemini/claude CLIs (vllm provider).",
6
6
  "providers": [
7
7
  "openai-codex"
@@ -36,4 +36,4 @@
36
36
  }
37
37
  }
38
38
  }
39
- }
39
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elvatis_com/openclaw-cli-bridge-elvatis",
3
- "version": "0.2.14",
3
+ "version": "0.2.16",
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": {
@@ -19,4 +19,4 @@
19
19
  "typescript": "^5.9.3",
20
20
  "vitest": "^4.0.18"
21
21
  }
22
- }
22
+ }
package/src/cli-runner.ts CHANGED
@@ -238,19 +238,55 @@ export async function runClaude(
238
238
  return result.stdout;
239
239
  }
240
240
 
241
+ // ──────────────────────────────────────────────────────────────────────────────
242
+ // Model allowlist (T-103)
243
+ // ──────────────────────────────────────────────────────────────────────────────
244
+
245
+ /**
246
+ * Default set of permitted models for the CLI bridge.
247
+ * Matches the models registered as slash commands in index.ts.
248
+ * Expressed as normalized "cli-<type>/<model-id>" strings (vllm/ prefix already stripped).
249
+ *
250
+ * To extend: pass a custom set to routeToCliRunner via the `allowedModels` option.
251
+ * To disable the check: pass `null` for `allowedModels`.
252
+ */
253
+ export const DEFAULT_ALLOWED_CLI_MODELS: ReadonlySet<string> = new Set([
254
+ // Claude Code CLI
255
+ "cli-claude/claude-sonnet-4-6",
256
+ "cli-claude/claude-opus-4-6",
257
+ "cli-claude/claude-haiku-4-5",
258
+ // Gemini CLI
259
+ "cli-gemini/gemini-2.5-pro",
260
+ "cli-gemini/gemini-2.5-flash",
261
+ "cli-gemini/gemini-3-pro",
262
+ ]);
263
+
241
264
  // ──────────────────────────────────────────────────────────────────────────────
242
265
  // Router
243
266
  // ──────────────────────────────────────────────────────────────────────────────
244
267
 
268
+ export interface RouteOptions {
269
+ /**
270
+ * Explicit model allowlist (normalized, vllm/ stripped).
271
+ * Pass `null` to disable the allowlist check entirely.
272
+ * Defaults to DEFAULT_ALLOWED_CLI_MODELS.
273
+ */
274
+ allowedModels?: ReadonlySet<string> | null;
275
+ }
276
+
245
277
  /**
246
278
  * Route a chat completion to the correct CLI based on model prefix.
247
279
  * cli-gemini/<id> → gemini CLI
248
280
  * cli-claude/<id> → claude CLI
281
+ *
282
+ * Enforces DEFAULT_ALLOWED_CLI_MODELS by default (T-103).
283
+ * Pass `allowedModels: null` to skip the allowlist check.
249
284
  */
250
285
  export async function routeToCliRunner(
251
286
  model: string,
252
287
  messages: ChatMessage[],
253
- timeoutMs: number
288
+ timeoutMs: number,
289
+ opts: RouteOptions = {}
254
290
  ): Promise<string> {
255
291
  const prompt = formatPrompt(messages);
256
292
 
@@ -259,6 +295,18 @@ export async function routeToCliRunner(
259
295
  // "cli-<type>/<model>" portion.
260
296
  const normalized = model.startsWith("vllm/") ? model.slice(5) : model;
261
297
 
298
+ // T-103: enforce allowlist unless explicitly disabled
299
+ const allowedModels = opts.allowedModels === undefined
300
+ ? DEFAULT_ALLOWED_CLI_MODELS
301
+ : opts.allowedModels;
302
+
303
+ if (allowedModels !== null && !allowedModels.has(normalized)) {
304
+ const known = [...(allowedModels)].join(", ");
305
+ throw new Error(
306
+ `CLI bridge model not allowed: "${model}". Allowed: ${known || "(none)"}.`
307
+ );
308
+ }
309
+
262
310
  if (normalized.startsWith("cli-gemini/")) return runGemini(prompt, normalized, timeoutMs);
263
311
  if (normalized.startsWith("cli-claude/")) return runClaude(prompt, normalized, timeoutMs);
264
312
 
@@ -1,5 +1,13 @@
1
- import { describe, it, expect, vi } from "vitest";
2
- import { formatPrompt, routeToCliRunner } from "../src/cli-runner.js";
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ formatPrompt,
4
+ routeToCliRunner,
5
+ DEFAULT_ALLOWED_CLI_MODELS,
6
+ } from "../src/cli-runner.js";
7
+
8
+ // ──────────────────────────────────────────────────────────────────────────────
9
+ // formatPrompt
10
+ // ──────────────────────────────────────────────────────────────────────────────
3
11
 
4
12
  describe("formatPrompt", () => {
5
13
  it("returns empty string for empty messages", () => {
@@ -17,10 +25,8 @@ describe("formatPrompt", () => {
17
25
  content: `msg ${i}`,
18
26
  }));
19
27
  const result = formatPrompt(messages);
20
- // Should contain last 20 messages, not first 10
21
28
  expect(result).toContain("msg 29");
22
29
  expect(result).not.toContain("msg 0\n");
23
- // Single-turn mode doesn't apply when there are multiple messages
24
30
  expect(result).toContain("[User]");
25
31
  });
26
32
 
@@ -33,8 +39,8 @@ describe("formatPrompt", () => {
33
39
  const result = formatPrompt([sys, ...msgs]);
34
40
  expect(result).toContain("[System]");
35
41
  expect(result).toContain("You are helpful");
36
- expect(result).toContain("msg 24"); // last
37
- expect(result).not.toContain("msg 0\n"); // first (truncated)
42
+ expect(result).toContain("msg 24");
43
+ expect(result).not.toContain("msg 0\n");
38
44
  });
39
45
 
40
46
  it("truncates individual message content at MAX_MSG_CHARS (4000)", () => {
@@ -43,32 +49,176 @@ describe("formatPrompt", () => {
43
49
  expect(result.length).toBeLessThan(5000);
44
50
  expect(result).toContain("truncated");
45
51
  });
52
+
53
+ // T-101: additional formatPrompt cases
54
+
55
+ it("formats a multi-turn user/assistant conversation with role labels", () => {
56
+ const messages = [
57
+ { role: "user" as const, content: "What is 2+2?" },
58
+ { role: "assistant" as const, content: "4" },
59
+ { role: "user" as const, content: "And 3+3?" },
60
+ ];
61
+ const result = formatPrompt(messages);
62
+ expect(result).toContain("[User]\nWhat is 2+2?");
63
+ expect(result).toContain("[Assistant]\n4");
64
+ expect(result).toContain("[User]\nAnd 3+3?");
65
+ });
66
+
67
+ it("single assistant message gets role label (not bare)", () => {
68
+ const result = formatPrompt([{ role: "assistant", content: "I am an assistant" }]);
69
+ expect(result).toContain("[Assistant]");
70
+ });
71
+
72
+ it("content exactly at MAX_MSG_CHARS (4000) is NOT truncated", () => {
73
+ const exact = "a".repeat(4000);
74
+ const result = formatPrompt([{ role: "user", content: exact }]);
75
+ expect(result).toBe(exact);
76
+ expect(result).not.toContain("truncated");
77
+ });
78
+
79
+ it("content well above MAX_MSG_CHARS is truncated to a shorter result", () => {
80
+ // Use 6000 chars — truncation suffix (~23 chars) + 4000 body = 4023, well below 6000
81
+ const over = "a".repeat(6000);
82
+ const result = formatPrompt([{ role: "user", content: over }]);
83
+ expect(result).toContain("truncated");
84
+ expect(result.length).toBeLessThan(over.length);
85
+ expect(result.startsWith("a".repeat(4000))).toBe(true);
86
+ });
87
+
88
+ it("system-only message uses role label (not bare)", () => {
89
+ const result = formatPrompt([{ role: "system", content: "Be concise" }]);
90
+ expect(result).toContain("[System]");
91
+ expect(result).toContain("Be concise");
92
+ });
93
+
94
+ it("system + single user message uses role labels (not bare)", () => {
95
+ const result = formatPrompt([
96
+ { role: "system", content: "Be concise" },
97
+ { role: "user", content: "Hi" },
98
+ ]);
99
+ expect(result).toContain("[System]");
100
+ expect(result).toContain("[User]");
101
+ });
46
102
  });
47
103
 
104
+ // ──────────────────────────────────────────────────────────────────────────────
105
+ // routeToCliRunner — model normalization + routing (T-101)
106
+ // ──────────────────────────────────────────────────────────────────────────────
107
+
48
108
  describe("routeToCliRunner — model normalization", () => {
49
109
  it("rejects unknown model without vllm prefix", async () => {
50
110
  await expect(
51
- routeToCliRunner("unknown/model", [], 1000)
111
+ routeToCliRunner("unknown/model", [], 1000, { allowedModels: null })
52
112
  ).rejects.toThrow("Unknown CLI bridge model");
53
113
  });
54
114
 
55
115
  it("rejects unknown model with vllm prefix", async () => {
56
116
  await expect(
57
- routeToCliRunner("vllm/unknown/model", [], 1000)
117
+ routeToCliRunner("vllm/unknown/model", [], 1000, { allowedModels: null })
58
118
  ).rejects.toThrow("Unknown CLI bridge model");
59
119
  });
60
120
 
61
121
  it("accepts cli-claude/ without vllm prefix (calls runClaude path)", async () => {
62
- // Should throw CLI spawn error (no real claude), not "Unknown CLI bridge model"
63
122
  await expect(
64
123
  routeToCliRunner("cli-claude/claude-sonnet-4-6", [{ role: "user", content: "hi" }], 500)
65
124
  ).rejects.not.toThrow("Unknown CLI bridge model");
66
125
  });
67
126
 
68
127
  it("accepts vllm/cli-claude/ — strips vllm prefix before routing", async () => {
69
- // Should throw CLI spawn error (no real claude), not "Unknown CLI bridge model"
70
128
  await expect(
71
129
  routeToCliRunner("vllm/cli-claude/claude-sonnet-4-6", [{ role: "user", content: "hi" }], 500)
72
130
  ).rejects.not.toThrow("Unknown CLI bridge model");
73
131
  });
132
+
133
+ // T-101: gemini routing paths
134
+
135
+ it("accepts cli-gemini/ without vllm prefix (routes to gemini, not 'unknown')", async () => {
136
+ // gemini CLI may resolve or reject — what matters is it doesn't throw "Unknown CLI bridge model"
137
+ let errorMsg = "";
138
+ try {
139
+ await routeToCliRunner("cli-gemini/gemini-2.5-pro", [{ role: "user", content: "hi" }], 500);
140
+ } catch (e: any) {
141
+ errorMsg = e?.message ?? "";
142
+ }
143
+ expect(errorMsg).not.toContain("Unknown CLI bridge model");
144
+ });
145
+
146
+ it("accepts vllm/cli-gemini/ — strips vllm prefix before routing", async () => {
147
+ let errorMsg = "";
148
+ try {
149
+ await routeToCliRunner("vllm/cli-gemini/gemini-2.5-flash", [{ role: "user", content: "hi" }], 500);
150
+ } catch (e: any) {
151
+ errorMsg = e?.message ?? "";
152
+ }
153
+ expect(errorMsg).not.toContain("Unknown CLI bridge model");
154
+ });
155
+
156
+ it("rejects bare 'vllm/' with no type segment", async () => {
157
+ await expect(
158
+ routeToCliRunner("vllm/", [], 1000, { allowedModels: null })
159
+ ).rejects.toThrow("Unknown CLI bridge model");
160
+ });
161
+
162
+ it("rejects empty model string", async () => {
163
+ await expect(
164
+ routeToCliRunner("", [], 1000, { allowedModels: null })
165
+ ).rejects.toThrow("Unknown CLI bridge model");
166
+ });
167
+ });
168
+
169
+ // ──────────────────────────────────────────────────────────────────────────────
170
+ // routeToCliRunner — model allowlist (T-103)
171
+ // ──────────────────────────────────────────────────────────────────────────────
172
+
173
+ describe("routeToCliRunner — model allowlist (T-103)", () => {
174
+ it("DEFAULT_ALLOWED_CLI_MODELS includes all registered claude models", () => {
175
+ expect(DEFAULT_ALLOWED_CLI_MODELS.has("cli-claude/claude-sonnet-4-6")).toBe(true);
176
+ expect(DEFAULT_ALLOWED_CLI_MODELS.has("cli-claude/claude-opus-4-6")).toBe(true);
177
+ expect(DEFAULT_ALLOWED_CLI_MODELS.has("cli-claude/claude-haiku-4-5")).toBe(true);
178
+ });
179
+
180
+ it("DEFAULT_ALLOWED_CLI_MODELS includes all registered gemini models", () => {
181
+ expect(DEFAULT_ALLOWED_CLI_MODELS.has("cli-gemini/gemini-2.5-pro")).toBe(true);
182
+ expect(DEFAULT_ALLOWED_CLI_MODELS.has("cli-gemini/gemini-2.5-flash")).toBe(true);
183
+ expect(DEFAULT_ALLOWED_CLI_MODELS.has("cli-gemini/gemini-3-pro")).toBe(true);
184
+ });
185
+
186
+ it("rejects a model not in the default allowlist", async () => {
187
+ await expect(
188
+ routeToCliRunner("vllm/cli-claude/claude-unknown-99", [{ role: "user", content: "hi" }], 500)
189
+ ).rejects.toThrow("CLI bridge model not allowed");
190
+ });
191
+
192
+ it("rejects an unregistered gemini model", async () => {
193
+ await expect(
194
+ routeToCliRunner("vllm/cli-gemini/gemini-1.0-pro", [{ role: "user", content: "hi" }], 500)
195
+ ).rejects.toThrow("CLI bridge model not allowed");
196
+ });
197
+
198
+ it("error message lists allowed models", async () => {
199
+ try {
200
+ await routeToCliRunner("vllm/cli-claude/bad-model", [{ role: "user", content: "hi" }], 500);
201
+ } catch (e: any) {
202
+ expect(e.message).toContain("cli-claude/claude-sonnet-4-6");
203
+ }
204
+ });
205
+
206
+ it("allowedModels: null disables the check — only routing matters", async () => {
207
+ // With null allowlist, unknown-prefix models still throw "Unknown CLI bridge model"
208
+ await expect(
209
+ routeToCliRunner("vllm/cli-claude/any-model", [{ role: "user", content: "hi" }], 500, {
210
+ allowedModels: null,
211
+ })
212
+ ).rejects.not.toThrow("CLI bridge model not allowed");
213
+ });
214
+
215
+ it("custom allowlist overrides defaults", async () => {
216
+ const custom = new Set(["cli-claude/claude-sonnet-4-6"]);
217
+ // claude-opus is in defaults but NOT in custom — should be rejected
218
+ await expect(
219
+ routeToCliRunner("vllm/cli-claude/claude-opus-4-6", [{ role: "user", content: "hi" }], 500, {
220
+ allowedModels: custom,
221
+ })
222
+ ).rejects.toThrow("CLI bridge model not allowed");
223
+ });
74
224
  });
@@ -0,0 +1,397 @@
1
+ /**
2
+ * End-to-end tests for the CLI bridge proxy server.
3
+ *
4
+ * Starts a real HTTP server on a random port and validates all endpoints:
5
+ * - GET /health, /v1/health
6
+ * - GET /v1/models
7
+ * - POST /v1/chat/completions (non-streaming + streaming)
8
+ * - Auth enforcement
9
+ * - Error handling (bad JSON, missing fields, unknown model, 404)
10
+ * - vllm/ prefix stripping through the full stack
11
+ *
12
+ * routeToCliRunner is mocked so we don't need real CLIs installed.
13
+ */
14
+
15
+ import { describe, it, expect, beforeAll, afterAll, vi } from "vitest";
16
+ import http from "node:http";
17
+ import { startProxyServer, CLI_MODELS } from "../src/proxy-server.js";
18
+
19
+ // Mock cli-runner so we don't spawn real CLIs
20
+ vi.mock("../src/cli-runner.js", async (importOriginal) => {
21
+ const orig = await importOriginal<typeof import("../src/cli-runner.js")>();
22
+ return {
23
+ ...orig,
24
+ routeToCliRunner: vi.fn(async (model: string, _messages: unknown[], _timeout: number) => {
25
+ // Simulate the real router: strip vllm/ prefix, validate model
26
+ const normalized = model.startsWith("vllm/") ? model.slice(5) : model;
27
+ if (!normalized.startsWith("cli-gemini/") && !normalized.startsWith("cli-claude/")) {
28
+ throw new Error(`Unknown CLI bridge model: "${model}"`);
29
+ }
30
+ return `Mock response from ${normalized}`;
31
+ }),
32
+ };
33
+ });
34
+
35
+ const API_KEY = "test-key-e2e";
36
+ let server: http.Server;
37
+ let port: number;
38
+ const baseUrl = () => `http://127.0.0.1:${port}`;
39
+
40
+ // ──────────────────────────────────────────────────────────────────────────────
41
+ // Helpers
42
+ // ──────────────────────────────────────────────────────────────────────────────
43
+
44
+ function fetch(path: string, opts: {
45
+ method?: string;
46
+ headers?: Record<string, string>;
47
+ body?: string;
48
+ } = {}): Promise<{ status: number; headers: http.IncomingHttpHeaders; body: string }> {
49
+ return new Promise((resolve, reject) => {
50
+ const url = new URL(path, baseUrl());
51
+ const req = http.request(url, {
52
+ method: opts.method ?? "GET",
53
+ headers: opts.headers,
54
+ }, (res) => {
55
+ const chunks: Buffer[] = [];
56
+ res.on("data", (d: Buffer) => chunks.push(d));
57
+ res.on("end", () => {
58
+ resolve({
59
+ status: res.statusCode ?? 0,
60
+ headers: res.headers,
61
+ body: Buffer.concat(chunks).toString("utf8"),
62
+ });
63
+ });
64
+ });
65
+ req.on("error", reject);
66
+ if (opts.body) req.write(opts.body);
67
+ req.end();
68
+ });
69
+ }
70
+
71
+ function json(path: string, body: unknown, extraHeaders: Record<string, string> = {}) {
72
+ return fetch(path, {
73
+ method: "POST",
74
+ headers: {
75
+ "Content-Type": "application/json",
76
+ Authorization: `Bearer ${API_KEY}`,
77
+ ...extraHeaders,
78
+ },
79
+ body: JSON.stringify(body),
80
+ });
81
+ }
82
+
83
+ // ──────────────────────────────────────────────────────────────────────────────
84
+ // Setup / Teardown
85
+ // ──────────────────────────────────────────────────────────────────────────────
86
+
87
+ beforeAll(async () => {
88
+ // Use port 0 to let the OS pick a free port
89
+ server = await startProxyServer({
90
+ port: 0,
91
+ apiKey: API_KEY,
92
+ timeoutMs: 5_000,
93
+ log: () => {},
94
+ warn: () => {},
95
+ });
96
+ const addr = server.address();
97
+ port = typeof addr === "object" && addr ? addr.port : 0;
98
+ });
99
+
100
+ afterAll(async () => {
101
+ await new Promise<void>((resolve) => {
102
+ server.closeAllConnections();
103
+ server.close(() => resolve());
104
+ });
105
+ });
106
+
107
+ // ──────────────────────────────────────────────────────────────────────────────
108
+ // Health checks
109
+ // ──────────────────────────────────────────────────────────────────────────────
110
+
111
+ describe("GET /health", () => {
112
+ it("returns {status: ok}", async () => {
113
+ const res = await fetch("/health");
114
+ expect(res.status).toBe(200);
115
+ const body = JSON.parse(res.body);
116
+ expect(body.status).toBe("ok");
117
+ expect(body.service).toBe("openclaw-cli-bridge");
118
+ });
119
+ });
120
+
121
+ describe("GET /v1/health", () => {
122
+ it("returns {status: ok}", async () => {
123
+ const res = await fetch("/v1/health");
124
+ expect(res.status).toBe(200);
125
+ expect(JSON.parse(res.body).status).toBe("ok");
126
+ });
127
+ });
128
+
129
+ // ──────────────────────────────────────────────────────────────────────────────
130
+ // CORS preflight
131
+ // ──────────────────────────────────────────────────────────────────────────────
132
+
133
+ describe("OPTIONS (CORS preflight)", () => {
134
+ it("returns 204 with CORS headers", async () => {
135
+ const res = await fetch("/v1/chat/completions", { method: "OPTIONS" });
136
+ expect(res.status).toBe(204);
137
+ expect(res.headers["access-control-allow-origin"]).toBe("*");
138
+ expect(res.headers["access-control-allow-methods"]).toContain("POST");
139
+ });
140
+ });
141
+
142
+ // ──────────────────────────────────────────────────────────────────────────────
143
+ // Model listing
144
+ // ──────────────────────────────────────────────────────────────────────────────
145
+
146
+ describe("GET /v1/models", () => {
147
+ it("returns all CLI bridge models in OpenAI format", async () => {
148
+ const res = await fetch("/v1/models");
149
+ expect(res.status).toBe(200);
150
+ const body = JSON.parse(res.body);
151
+ expect(body.object).toBe("list");
152
+ expect(body.data).toHaveLength(CLI_MODELS.length);
153
+
154
+ const ids = body.data.map((m: { id: string }) => m.id);
155
+ for (const model of CLI_MODELS) {
156
+ expect(ids).toContain(model.id);
157
+ }
158
+
159
+ // Each model has the correct OpenAI shape
160
+ for (const m of body.data) {
161
+ expect(m.object).toBe("model");
162
+ expect(m.owned_by).toBe("openclaw-cli-bridge");
163
+ expect(typeof m.created).toBe("number");
164
+ }
165
+ });
166
+
167
+ it("includes CORS headers", async () => {
168
+ const res = await fetch("/v1/models");
169
+ expect(res.headers["access-control-allow-origin"]).toBe("*");
170
+ });
171
+ });
172
+
173
+ // ──────────────────────────────────────────────────────────────────────────────
174
+ // Chat completions — non-streaming
175
+ // ──────────────────────────────────────────────────────────────────────────────
176
+
177
+ describe("POST /v1/chat/completions (non-streaming)", () => {
178
+ it("returns valid OpenAI completion for cli-claude model", async () => {
179
+ const res = await json("/v1/chat/completions", {
180
+ model: "cli-claude/claude-sonnet-4-6",
181
+ messages: [{ role: "user", content: "hello" }],
182
+ stream: false,
183
+ });
184
+
185
+ expect(res.status).toBe(200);
186
+ const body = JSON.parse(res.body);
187
+ expect(body.object).toBe("chat.completion");
188
+ expect(body.id).toMatch(/^chatcmpl-cli-/);
189
+ expect(body.model).toBe("cli-claude/claude-sonnet-4-6");
190
+ expect(body.choices).toHaveLength(1);
191
+ expect(body.choices[0].message.role).toBe("assistant");
192
+ expect(body.choices[0].message.content).toBe("Mock response from cli-claude/claude-sonnet-4-6");
193
+ expect(body.choices[0].finish_reason).toBe("stop");
194
+ expect(body.usage).toBeDefined();
195
+ });
196
+
197
+ it("returns valid completion for cli-gemini model", async () => {
198
+ const res = await json("/v1/chat/completions", {
199
+ model: "cli-gemini/gemini-2.5-pro",
200
+ messages: [{ role: "user", content: "hi" }],
201
+ });
202
+
203
+ expect(res.status).toBe(200);
204
+ const body = JSON.parse(res.body);
205
+ expect(body.choices[0].message.content).toBe("Mock response from cli-gemini/gemini-2.5-pro");
206
+ });
207
+
208
+ it("handles vllm/ prefix (OpenClaw sends full provider path)", async () => {
209
+ const res = await json("/v1/chat/completions", {
210
+ model: "vllm/cli-claude/claude-haiku-4-5",
211
+ messages: [{ role: "user", content: "test" }],
212
+ });
213
+
214
+ expect(res.status).toBe(200);
215
+ const body = JSON.parse(res.body);
216
+ // Model in response should be the requested model (vllm/ prefix preserved in response)
217
+ expect(body.model).toBe("vllm/cli-claude/claude-haiku-4-5");
218
+ // But the mock receives the model and strips vllm/ internally
219
+ expect(body.choices[0].message.content).toBe("Mock response from cli-claude/claude-haiku-4-5");
220
+ });
221
+
222
+ it("handles vllm/ prefix for gemini models", async () => {
223
+ const res = await json("/v1/chat/completions", {
224
+ model: "vllm/cli-gemini/gemini-2.5-flash",
225
+ messages: [{ role: "user", content: "test" }],
226
+ });
227
+
228
+ expect(res.status).toBe(200);
229
+ const body = JSON.parse(res.body);
230
+ expect(body.choices[0].message.content).toBe("Mock response from cli-gemini/gemini-2.5-flash");
231
+ });
232
+
233
+ it("passes multi-turn conversation", async () => {
234
+ const res = await json("/v1/chat/completions", {
235
+ model: "cli-claude/claude-sonnet-4-6",
236
+ messages: [
237
+ { role: "system", content: "You are helpful" },
238
+ { role: "user", content: "What is 2+2?" },
239
+ { role: "assistant", content: "4" },
240
+ { role: "user", content: "And 3+3?" },
241
+ ],
242
+ });
243
+
244
+ expect(res.status).toBe(200);
245
+ const body = JSON.parse(res.body);
246
+ expect(body.choices[0].message.content).toContain("Mock response");
247
+ });
248
+ });
249
+
250
+ // ──────────────────────────────────────────────────────────────────────────────
251
+ // Chat completions — streaming (SSE)
252
+ // ──────────────────────────────────────────────────────────────────────────────
253
+
254
+ describe("POST /v1/chat/completions (streaming)", () => {
255
+ it("returns SSE stream with correct chunks", async () => {
256
+ const res = await json("/v1/chat/completions", {
257
+ model: "cli-claude/claude-sonnet-4-6",
258
+ messages: [{ role: "user", content: "hello" }],
259
+ stream: true,
260
+ });
261
+
262
+ expect(res.status).toBe(200);
263
+ expect(res.headers["content-type"]).toBe("text/event-stream");
264
+
265
+ // Parse SSE events
266
+ const events = res.body
267
+ .split("\n\n")
268
+ .filter((e) => e.startsWith("data: "))
269
+ .map((e) => e.replace("data: ", ""));
270
+
271
+ // Should end with [DONE]
272
+ expect(events[events.length - 1]).toBe("[DONE]");
273
+
274
+ // Parse JSON chunks (all except [DONE])
275
+ const chunks = events
276
+ .filter((e) => e !== "[DONE]")
277
+ .map((e) => JSON.parse(e));
278
+
279
+ // First chunk should have role delta
280
+ expect(chunks[0].choices[0].delta.role).toBe("assistant");
281
+ expect(chunks[0].object).toBe("chat.completion.chunk");
282
+
283
+ // Last JSON chunk should have finish_reason: "stop"
284
+ const lastChunk = chunks[chunks.length - 1];
285
+ expect(lastChunk.choices[0].finish_reason).toBe("stop");
286
+
287
+ // Content chunks should reassemble to the full response
288
+ const fullContent = chunks
289
+ .map((c) => c.choices[0].delta.content ?? "")
290
+ .join("");
291
+ expect(fullContent).toBe("Mock response from cli-claude/claude-sonnet-4-6");
292
+
293
+ // All chunks should have consistent id
294
+ const ids = new Set(chunks.map((c) => c.id));
295
+ expect(ids.size).toBe(1);
296
+ });
297
+ });
298
+
299
+ // ──────────────────────────────────────────────────────────────────────────────
300
+ // Auth enforcement
301
+ // ──────────────────────────────────────────────────────────────────────────────
302
+
303
+ describe("Auth enforcement", () => {
304
+ it("rejects request without Authorization header", async () => {
305
+ const res = await fetch("/v1/chat/completions", {
306
+ method: "POST",
307
+ headers: { "Content-Type": "application/json" },
308
+ body: JSON.stringify({
309
+ model: "cli-claude/claude-sonnet-4-6",
310
+ messages: [{ role: "user", content: "hi" }],
311
+ }),
312
+ });
313
+
314
+ expect(res.status).toBe(401);
315
+ expect(JSON.parse(res.body).error.type).toBe("auth_error");
316
+ });
317
+
318
+ it("rejects request with wrong API key", async () => {
319
+ const res = await fetch("/v1/chat/completions", {
320
+ method: "POST",
321
+ headers: {
322
+ "Content-Type": "application/json",
323
+ Authorization: "Bearer wrong-key",
324
+ },
325
+ body: JSON.stringify({
326
+ model: "cli-claude/claude-sonnet-4-6",
327
+ messages: [{ role: "user", content: "hi" }],
328
+ }),
329
+ });
330
+
331
+ expect(res.status).toBe(401);
332
+ });
333
+
334
+ it("accepts request with correct API key", async () => {
335
+ const res = await json("/v1/chat/completions", {
336
+ model: "cli-claude/claude-sonnet-4-6",
337
+ messages: [{ role: "user", content: "hi" }],
338
+ });
339
+
340
+ expect(res.status).toBe(200);
341
+ });
342
+ });
343
+
344
+ // ──────────────────────────────────────────────────────────────────────────────
345
+ // Error handling
346
+ // ──────────────────────────────────────────────────────────────────────────────
347
+
348
+ describe("Error handling", () => {
349
+ it("returns 400 for invalid JSON body", async () => {
350
+ const res = await fetch("/v1/chat/completions", {
351
+ method: "POST",
352
+ headers: {
353
+ "Content-Type": "application/json",
354
+ Authorization: `Bearer ${API_KEY}`,
355
+ },
356
+ body: "not json",
357
+ });
358
+
359
+ expect(res.status).toBe(400);
360
+ expect(JSON.parse(res.body).error.type).toBe("invalid_request_error");
361
+ });
362
+
363
+ it("returns 400 when model is missing", async () => {
364
+ const res = await json("/v1/chat/completions", {
365
+ messages: [{ role: "user", content: "hi" }],
366
+ });
367
+
368
+ expect(res.status).toBe(400);
369
+ expect(JSON.parse(res.body).error.message).toContain("model and messages are required");
370
+ });
371
+
372
+ it("returns 400 when messages is empty", async () => {
373
+ const res = await json("/v1/chat/completions", {
374
+ model: "cli-claude/claude-sonnet-4-6",
375
+ messages: [],
376
+ });
377
+
378
+ expect(res.status).toBe(400);
379
+ });
380
+
381
+ it("returns 500 for unknown model", async () => {
382
+ const res = await json("/v1/chat/completions", {
383
+ model: "unknown/model",
384
+ messages: [{ role: "user", content: "hi" }],
385
+ });
386
+
387
+ expect(res.status).toBe(500);
388
+ expect(JSON.parse(res.body).error.type).toBe("cli_error");
389
+ expect(JSON.parse(res.body).error.message).toContain("Unknown CLI bridge model");
390
+ });
391
+
392
+ it("returns 404 for unknown routes", async () => {
393
+ const res = await fetch("/v1/nonexistent");
394
+ expect(res.status).toBe(404);
395
+ expect(JSON.parse(res.body).error.type).toBe("not_found");
396
+ });
397
+ });