@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.
- package/.ai/handoff/MANIFEST.json +140 -22
- package/.ai/handoff/NEXT_ACTIONS.md +35 -28
- package/.ai/handoff/STATUS.md +29 -22
- package/README.md +18 -2
- package/SKILL.md +1 -1
- package/index.ts +1 -1
- package/openclaw.plugin.json +2 -2
- package/package.json +2 -2
- package/src/cli-runner.ts +49 -1
- package/test/cli-runner.test.ts +160 -10
- package/test/proxy-e2e.test.ts +397 -0
|
@@ -2,34 +2,152 @@
|
|
|
2
2
|
"aahp_version": "3.0",
|
|
3
3
|
"project": "openclaw-cli-bridge-elvatis",
|
|
4
4
|
"last_session": {
|
|
5
|
-
"agent": "
|
|
5
|
+
"agent": "claude-code",
|
|
6
6
|
"session_id": "main",
|
|
7
|
-
"timestamp": "2026-03-
|
|
8
|
-
"commit": "
|
|
7
|
+
"timestamp": "2026-03-08T08:08:44.175Z",
|
|
8
|
+
"commit": "30be8ae",
|
|
9
9
|
"phase": "integration",
|
|
10
|
-
"duration_minutes":
|
|
10
|
+
"duration_minutes": 2
|
|
11
11
|
},
|
|
12
12
|
"files": {
|
|
13
|
-
"STATUS.md": {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
"
|
|
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": {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
"T-
|
|
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-
|
|
3
|
+
_Last updated: 2026-03-08_
|
|
4
4
|
|
|
5
5
|
## Status Summary
|
|
6
6
|
|
|
7
|
-
| Status
|
|
8
|
-
|
|
9
|
-
| Done
|
|
10
|
-
| Ready
|
|
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-
|
|
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
|
-
|
|
23
|
-
- **
|
|
24
|
-
- **
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## 🚫 Blocked
|
|
36
|
+
|
|
37
|
+
_No blocked tasks._
|
|
29
38
|
|
|
30
39
|
---
|
|
31
40
|
|
|
32
|
-
## Recently Completed
|
|
33
|
-
|
|
34
|
-
| Task
|
|
35
|
-
|
|
36
|
-
| T-
|
|
37
|
-
| T-
|
|
38
|
-
| T-
|
|
39
|
-
| T-
|
|
40
|
-
| T-
|
|
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 |
|
package/.ai/handoff/STATUS.md
CHANGED
|
@@ -1,42 +1,49 @@
|
|
|
1
1
|
# STATUS.md — openclaw-cli-bridge-elvatis
|
|
2
2
|
|
|
3
|
-
_Last updated: 2026-03-
|
|
3
|
+
_Last updated: 2026-03-08 by Akido (claude-sonnet-4-6)_
|
|
4
4
|
|
|
5
|
-
## Current Version: 0.2.
|
|
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.
|
|
11
|
-
- ✅ ClawHub: `openclaw-cli-bridge-elvatis@0.2.
|
|
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
|
|
18
|
-
- ✅ `
|
|
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.
|
|
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
|
|
31
|
-
Added `closeAllConnections()` + `registerService` stop() hook.
|
|
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.
|
|
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.
|
|
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
|
-
- **
|
|
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
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.
|
|
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. " +
|
package/openclaw.plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "openclaw-cli-bridge-elvatis",
|
|
3
3
|
"name": "OpenClaw CLI Bridge",
|
|
4
|
-
"version": "0.2.
|
|
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.
|
|
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
|
|
package/test/cli-runner.test.ts
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
|
-
import { describe, it, expect
|
|
2
|
-
import {
|
|
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");
|
|
37
|
-
expect(result).not.toContain("msg 0\n");
|
|
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
|
+
});
|