@elvatis_com/openclaw-cli-bridge-elvatis 2.2.2 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.ai/handoff/DASHBOARD.md +7 -5
- package/.ai/handoff/LOG.md +19 -0
- package/.ai/handoff/NEXT_ACTIONS.md +2 -1
- package/.ai/handoff/STATUS.md +12 -11
- package/README.md +9 -1
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/src/cli-runner.ts +206 -25
- package/src/metrics.ts +85 -0
- package/src/proxy-server.ts +135 -50
- package/src/status-template.ts +122 -0
- package/src/tool-protocol.ts +269 -0
- package/test/cli-runner-extended.test.ts +4 -4
- package/test/cli-runner.test.ts +3 -2
- package/test/proxy-e2e.test.ts +31 -28
package/.ai/handoff/DASHBOARD.md
CHANGED
|
@@ -7,7 +7,7 @@ _Last updated: 2026-04-10_
|
|
|
7
7
|
|
|
8
8
|
| Component | Version | Build | Tests | Status |
|
|
9
9
|
|-----------|---------|-------|-------|--------|
|
|
10
|
-
| openclaw-cli-bridge-elvatis | 2.
|
|
10
|
+
| openclaw-cli-bridge-elvatis | 2.4.0 | ✅ | ✅ | ✅ Stable |
|
|
11
11
|
<!-- /SECTION: plugin_status -->
|
|
12
12
|
|
|
13
13
|
<!-- SECTION: release_state -->
|
|
@@ -15,9 +15,9 @@ _Last updated: 2026-04-10_
|
|
|
15
15
|
|
|
16
16
|
| Platform | Published Version | Status |
|
|
17
17
|
|----------|------------------|--------|
|
|
18
|
-
| GitHub | v2.
|
|
19
|
-
| npm | 2.
|
|
20
|
-
| ClawHub | 2.
|
|
18
|
+
| GitHub | v2.4.0 | ✅ Pushed to main |
|
|
19
|
+
| npm | 2.4.0 | ⏳ Pending (via CI) |
|
|
20
|
+
| ClawHub | 2.4.0 | ⏳ Pending (via CI) |
|
|
21
21
|
<!-- /SECTION: release_state -->
|
|
22
22
|
|
|
23
23
|
<!-- SECTION: open_tasks -->
|
|
@@ -31,7 +31,9 @@ _No open tasks._
|
|
|
31
31
|
|
|
32
32
|
| Task | Title | Version |
|
|
33
33
|
|------|-------|---------|
|
|
34
|
-
| T-
|
|
34
|
+
| T-020 | Metrics & health dashboard: request volume, latency, errors, token usage | 2.4.0 |
|
|
35
|
+
| T-019 | Full-featured CLI bridge: tool calls + multimodal + autonomous execution | 2.3.0 |
|
|
36
|
+
| T-018 | Fix vllm apiKey corruption (401) + harden config-patcher | 2.2.1 |
|
|
35
37
|
| T-017 | Fix log spam, restart loops, CLI blocking | 2.2.0 |
|
|
36
38
|
| T-016 | Issue #2: Codex auth auto-import into agent auth store | 2.1.0 |
|
|
37
39
|
| T-015 | Issue #4: Background session mgmt with workdir isolation | 2.1.0 |
|
package/.ai/handoff/LOG.md
CHANGED
|
@@ -4,6 +4,25 @@ _Last 10 sessions. Older entries in LOG-ARCHIVE.md._
|
|
|
4
4
|
|
|
5
5
|
---
|
|
6
6
|
|
|
7
|
+
## 2026-04-10 — Session 11 (Claude Opus 4.6)
|
|
8
|
+
|
|
9
|
+
> **Agent:** claude-opus-4-6
|
|
10
|
+
> **Phase:** feature
|
|
11
|
+
> **Commit before:** v2.2.2
|
|
12
|
+
> **Commit after:** v2.3.0
|
|
13
|
+
|
|
14
|
+
**Full-featured CLI bridge: tool calls + multimodal + autonomous execution**
|
|
15
|
+
|
|
16
|
+
- Added OpenAI tool calling protocol: tool definitions injected into prompts, structured `tool_calls` parsed from CLI output
|
|
17
|
+
- Added multimodal content support: images/audio extracted to temp files, passed to CLIs via native mechanisms
|
|
18
|
+
- Autonomous execution: Claude `--permission-mode bypassPermissions`, Gemini `--approval-mode yolo`
|
|
19
|
+
- New `src/tool-protocol.ts` module (prompt builder, response parser, Claude wrapper extraction)
|
|
20
|
+
- Removed HTTP 400 tool rejection — all CLI models now accept tool-calling requests
|
|
21
|
+
- Model capabilities report `tools: true` for all CLI models
|
|
22
|
+
- Reverted self-healing DEFAULT_MODEL_ORDER back to CLI models
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
7
26
|
## 2026-04-10 — Session 10 (Claude Opus 4.6)
|
|
8
27
|
|
|
9
28
|
> **Agent:** claude-opus-4-6
|
|
@@ -7,7 +7,7 @@ _Last updated: 2026-04-10_
|
|
|
7
7
|
|
|
8
8
|
| Status | Count |
|
|
9
9
|
|---------|-------|
|
|
10
|
-
| Done |
|
|
10
|
+
| Done | 19 |
|
|
11
11
|
| Ready | 0 |
|
|
12
12
|
| Blocked | 0 |
|
|
13
13
|
<!-- /SECTION: summary -->
|
|
@@ -30,6 +30,7 @@ _No blocked tasks._
|
|
|
30
30
|
|
|
31
31
|
| Task | Title | Date |
|
|
32
32
|
|-------|--------------------------------------------------------------------|------------|
|
|
33
|
+
| T-019 | Full CLI bridge: tool calls + multimodal + autonomous (v2.3.0) | 2026-04-10 |
|
|
33
34
|
| T-018 | Fix vllm apiKey corruption (401) + harden config-patcher (v2.2.1)| 2026-04-10 |
|
|
34
35
|
| T-017 | Fix log spam, restart loops, CLI blocking (v2.2.0) | 2026-04-09 |
|
|
35
36
|
| T-016 | Issue #2: Codex auth auto-import into agent auth store | 2026-03-19 |
|
package/.ai/handoff/STATUS.md
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# STATUS — openclaw-cli-bridge-elvatis
|
|
2
2
|
|
|
3
|
-
## Current Version: 2.
|
|
3
|
+
## Current Version: 2.3.0
|
|
4
4
|
|
|
5
|
-
- **npm:** @elvatis_com/openclaw-cli-bridge-elvatis@2.
|
|
6
|
-
- **ClawHub:** openclaw-cli-bridge-elvatis@2.
|
|
5
|
+
- **npm:** @elvatis_com/openclaw-cli-bridge-elvatis@2.3.0
|
|
6
|
+
- **ClawHub:** openclaw-cli-bridge-elvatis@2.3.0
|
|
7
7
|
- **GitHub:** https://github.com/elvatis/openclaw-cli-bridge-elvatis (pushed to main)
|
|
8
8
|
|
|
9
9
|
## CLI Model Token Limits (corrected in v1.9.2)
|
|
@@ -20,18 +20,18 @@
|
|
|
20
20
|
## Architecture
|
|
21
21
|
- **Proxy server:** `http://127.0.0.1:31337/v1` (OpenAI-compatible)
|
|
22
22
|
- **OpenClaw connects via** `vllm` provider with `api: openai-completions`
|
|
23
|
-
- **CLI models** (`cli-claude/*`, `cli-gemini/*`):
|
|
23
|
+
- **CLI models** (`cli-claude/*`, `cli-gemini/*`, `openai-codex/*`): full tool calling + multimodal support via prompt injection + autonomous execution
|
|
24
24
|
- **Web-session models** (`web-grok/*`, `web-gemini/*`): browser-based, require `/xxx-login`
|
|
25
25
|
- **Codex models** (`openai-codex/*`): OAuth auth bridge
|
|
26
26
|
- **BitNet** (`local-bitnet/*`): local CPU inference
|
|
27
27
|
|
|
28
|
-
## Tool Support
|
|
29
|
-
CLI models
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
28
|
+
## Tool Calling Support (v2.3.0)
|
|
29
|
+
All CLI models now support the OpenAI tool calling protocol:
|
|
30
|
+
- Tool definitions are injected into the prompt as structured instructions
|
|
31
|
+
- CLI output is parsed for structured `tool_calls` JSON responses
|
|
32
|
+
- Responses are returned in standard OpenAI `tool_calls` format with `finish_reason: "tool_calls"`
|
|
33
|
+
- Multimodal content (images, audio) is extracted to temp files and passed to CLIs
|
|
34
|
+
- All models run in autonomous mode: Claude `bypassPermissions`, Gemini `yolo`, Codex `full-auto`
|
|
35
35
|
|
|
36
36
|
## All 4 Browser Providers
|
|
37
37
|
| Provider | Models | Login Cmd | Profile Dir |
|
|
@@ -47,6 +47,7 @@ This is by design — CLI tools output plain text only.
|
|
|
47
47
|
- /bridge-status shows cookie-based status
|
|
48
48
|
|
|
49
49
|
## Release History (recent)
|
|
50
|
+
- v2.3.0 (2026-04-10): Tool calling protocol, multimodal content, autonomous execution mode
|
|
50
51
|
- v2.2.1 (2026-04-10): Fix vllm apiKey corruption (401 Unauthorized) + harden config-patcher to re-patch on wrong apiKey
|
|
51
52
|
- v2.2.0 (2026-04-09): Fix log spam (module-level guards), remove fuser -k restart loops, session restore gateway-only, EADDRINUSE graceful handling
|
|
52
53
|
- v2.1.0 (2026-03-19): Issue #6 workdir isolation, Issue #4 session mgmt enhancements, Issue #2 codex auth auto-import
|
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
> OpenClaw plugin that bridges locally installed AI CLIs (Codex, Gemini, Claude Code, OpenCode, Pi) as model providers — with slash commands for instant model switching, restore, health testing, and model listing.
|
|
4
4
|
|
|
5
|
-
**Current version:** `2.
|
|
5
|
+
**Current version:** `2.3.0`
|
|
6
6
|
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -376,6 +376,14 @@ npm run ci # lint + typecheck + test
|
|
|
376
376
|
|
|
377
377
|
## Changelog
|
|
378
378
|
|
|
379
|
+
### v2.3.0
|
|
380
|
+
- **feat:** OpenAI tool calling protocol support for all CLI models — tool definitions are injected into the prompt, structured `tool_calls` responses are parsed and returned in OpenAI format
|
|
381
|
+
- **feat:** Multimodal content support — images and audio from webchat are extracted to temp files and passed to CLIs (Codex uses native `-i` flag, Claude/Gemini reference file paths in prompt)
|
|
382
|
+
- **feat:** Autonomous execution mode — Claude uses `--permission-mode bypassPermissions`, Gemini uses `--approval-mode yolo`, Codex uses `--full-auto`. CLI models never ask interactive questions.
|
|
383
|
+
- **feat:** New `src/tool-protocol.ts` module — tool prompt builder, response parser, call ID generator
|
|
384
|
+
- **fix:** Removed `--tools ""` from Claude CLI args — allows native tool execution when needed
|
|
385
|
+
- **fix:** Model capabilities now report `tools: true` for all CLI models (was `false`)
|
|
386
|
+
|
|
379
387
|
### v2.2.1
|
|
380
388
|
- **fix:** Config-patcher now validates `apiKey` value — re-patches if `__OPENCLAW_KEEP__` or any wrong value is present (prevents vllm 401 Unauthorized after config migrations)
|
|
381
389
|
|
package/openclaw.plugin.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"id": "openclaw-cli-bridge-elvatis",
|
|
3
3
|
"slug": "openclaw-cli-bridge-elvatis",
|
|
4
4
|
"name": "OpenClaw CLI Bridge",
|
|
5
|
-
"version": "2.
|
|
5
|
+
"version": "2.4.0",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"description": "Phase 1: openai-codex auth bridge. Phase 2: local HTTP proxy routing model calls through gemini/claude CLIs (vllm provider).",
|
|
8
8
|
"providers": [
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@elvatis_com/openclaw-cli-bridge-elvatis",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.4.0",
|
|
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": {
|
package/src/cli-runner.ts
CHANGED
|
@@ -18,9 +18,16 @@
|
|
|
18
18
|
|
|
19
19
|
import { spawn, execSync } from "node:child_process";
|
|
20
20
|
import { tmpdir, homedir } from "node:os";
|
|
21
|
-
import { existsSync } from "node:fs";
|
|
21
|
+
import { existsSync, writeFileSync, unlinkSync, mkdirSync } from "node:fs";
|
|
22
22
|
import { join } from "node:path";
|
|
23
|
+
import { randomBytes } from "node:crypto";
|
|
23
24
|
import { ensureClaudeToken, refreshClaudeToken } from "./claude-auth.js";
|
|
25
|
+
import {
|
|
26
|
+
type ToolDefinition,
|
|
27
|
+
type CliToolResult,
|
|
28
|
+
buildToolPromptBlock,
|
|
29
|
+
parseToolCallResponse,
|
|
30
|
+
} from "./tool-protocol.js";
|
|
24
31
|
|
|
25
32
|
/** Max messages to include in the prompt sent to the CLI. */
|
|
26
33
|
const MAX_MESSAGES = 20;
|
|
@@ -37,15 +44,28 @@ export interface ContentPart {
|
|
|
37
44
|
}
|
|
38
45
|
|
|
39
46
|
export interface ChatMessage {
|
|
40
|
-
role: "system" | "user" | "assistant";
|
|
47
|
+
role: "system" | "user" | "assistant" | "tool";
|
|
41
48
|
/** Plain string or OpenAI-style content array (multimodal / structured). */
|
|
42
49
|
content: string | ContentPart[] | unknown;
|
|
50
|
+
/** Tool calls made by the assistant (OpenAI tool calling protocol). */
|
|
51
|
+
tool_calls?: Array<{ id: string; type: string; function: { name: string; arguments: string } }>;
|
|
52
|
+
/** ID linking a tool result to the assistant's tool_call. */
|
|
53
|
+
tool_call_id?: string;
|
|
54
|
+
/** Function name for tool result messages. */
|
|
55
|
+
name?: string;
|
|
43
56
|
}
|
|
44
57
|
|
|
58
|
+
// Re-export tool-protocol types for convenience
|
|
59
|
+
export type { ToolDefinition, CliToolResult } from "./tool-protocol.js";
|
|
60
|
+
|
|
45
61
|
/**
|
|
46
62
|
* Convert OpenAI messages to a single flat prompt string.
|
|
47
63
|
* Truncates to MAX_MESSAGES (keeping the most recent) and MAX_MSG_CHARS per
|
|
48
64
|
* message to avoid oversized payloads.
|
|
65
|
+
*
|
|
66
|
+
* Handles tool-calling messages:
|
|
67
|
+
* - role "tool": formatted as [Tool Result: name]
|
|
68
|
+
* - role "assistant" with tool_calls: formatted as [Assistant Tool Call: name(args)]
|
|
49
69
|
*/
|
|
50
70
|
export function formatPrompt(messages: ChatMessage[]): string {
|
|
51
71
|
if (messages.length === 0) return "";
|
|
@@ -63,6 +83,22 @@ export function formatPrompt(messages: ChatMessage[]): string {
|
|
|
63
83
|
|
|
64
84
|
return truncated
|
|
65
85
|
.map((m) => {
|
|
86
|
+
// Assistant message with tool_calls (no text content)
|
|
87
|
+
if (m.role === "assistant" && m.tool_calls?.length) {
|
|
88
|
+
const calls = m.tool_calls.map((tc) =>
|
|
89
|
+
`[Assistant Tool Call: ${tc.function.name}(${tc.function.arguments})]\n`
|
|
90
|
+
).join("");
|
|
91
|
+
const content = m.content ? truncateContent(m.content) : "";
|
|
92
|
+
return content ? `${calls}\n${content}` : calls.trimEnd();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Tool result message
|
|
96
|
+
if (m.role === "tool") {
|
|
97
|
+
const name = m.name ?? "unknown";
|
|
98
|
+
const content = truncateContent(m.content);
|
|
99
|
+
return `[Tool Result: ${name}]\n${content}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
66
102
|
const content = truncateContent(m.content);
|
|
67
103
|
switch (m.role) {
|
|
68
104
|
case "system": return `[System]\n${content}`;
|
|
@@ -79,7 +115,7 @@ export function formatPrompt(messages: ChatMessage[]): string {
|
|
|
79
115
|
*
|
|
80
116
|
* Handles:
|
|
81
117
|
* - string → as-is
|
|
82
|
-
* - ContentPart[] → join text parts (
|
|
118
|
+
* - ContentPart[] → join text parts + describe non-text parts (multimodal)
|
|
83
119
|
* - other object → JSON.stringify (prevents "[object Object]" from reaching the CLI)
|
|
84
120
|
* - null/undefined → ""
|
|
85
121
|
*/
|
|
@@ -87,9 +123,14 @@ function contentToString(content: unknown): string {
|
|
|
87
123
|
if (typeof content === "string") return content;
|
|
88
124
|
if (content === null || content === undefined) return "";
|
|
89
125
|
if (Array.isArray(content)) {
|
|
90
|
-
return (content as
|
|
91
|
-
.
|
|
92
|
-
|
|
126
|
+
return (content as Record<string, unknown>[])
|
|
127
|
+
.map((c) => {
|
|
128
|
+
if (c?.type === "text" && typeof c.text === "string") return c.text;
|
|
129
|
+
if (c?.type === "image_url") return "[Attached image — see saved media file]";
|
|
130
|
+
if (c?.type === "input_audio") return "[Attached audio — see saved media file]";
|
|
131
|
+
return null;
|
|
132
|
+
})
|
|
133
|
+
.filter(Boolean)
|
|
93
134
|
.join("\n");
|
|
94
135
|
}
|
|
95
136
|
if (typeof content === "object") return JSON.stringify(content);
|
|
@@ -102,6 +143,92 @@ function truncateContent(raw: unknown): string {
|
|
|
102
143
|
return s.slice(0, MAX_MSG_CHARS) + `\n...[truncated ${s.length - MAX_MSG_CHARS} chars]`;
|
|
103
144
|
}
|
|
104
145
|
|
|
146
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
147
|
+
// Multimodal content extraction
|
|
148
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
export interface MediaFile {
|
|
151
|
+
path: string;
|
|
152
|
+
mimeType: string;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const MEDIA_TMP_DIR = join(tmpdir(), "cli-bridge-media");
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Extract non-text content parts (images, audio) from messages.
|
|
159
|
+
* Saves base64 data to temp files and replaces media parts with file references.
|
|
160
|
+
* Returns cleaned messages + list of saved media files for CLI -i flags.
|
|
161
|
+
*/
|
|
162
|
+
export function extractMultimodalParts(messages: ChatMessage[]): {
|
|
163
|
+
cleanMessages: ChatMessage[];
|
|
164
|
+
mediaFiles: MediaFile[];
|
|
165
|
+
} {
|
|
166
|
+
const mediaFiles: MediaFile[] = [];
|
|
167
|
+
const cleanMessages = messages.map((m) => {
|
|
168
|
+
if (!Array.isArray(m.content)) return m;
|
|
169
|
+
|
|
170
|
+
const parts = m.content as Record<string, unknown>[];
|
|
171
|
+
const newParts: Record<string, unknown>[] = [];
|
|
172
|
+
|
|
173
|
+
for (const part of parts) {
|
|
174
|
+
if (part?.type === "image_url") {
|
|
175
|
+
const imageUrl = (part as { image_url?: { url?: string } }).image_url;
|
|
176
|
+
const url = imageUrl?.url ?? "";
|
|
177
|
+
if (url.startsWith("data:")) {
|
|
178
|
+
// data:image/png;base64,iVBOR...
|
|
179
|
+
const match = url.match(/^data:(image\/\w+);base64,(.+)$/);
|
|
180
|
+
if (match) {
|
|
181
|
+
const ext = match[1].split("/")[1] || "png";
|
|
182
|
+
const filePath = saveBase64ToTemp(match[2], ext);
|
|
183
|
+
mediaFiles.push({ path: filePath, mimeType: match[1] });
|
|
184
|
+
newParts.push({ type: "text", text: `[Attached image: ${filePath}]` });
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
// URL-based image — include URL reference in text
|
|
189
|
+
newParts.push({ type: "text", text: `[Image URL: ${url}]` });
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (part?.type === "input_audio") {
|
|
194
|
+
const audioData = (part as { input_audio?: { data?: string; format?: string } }).input_audio;
|
|
195
|
+
if (audioData?.data) {
|
|
196
|
+
const ext = audioData.format || "wav";
|
|
197
|
+
const filePath = saveBase64ToTemp(audioData.data, ext);
|
|
198
|
+
mediaFiles.push({ path: filePath, mimeType: `audio/${ext}` });
|
|
199
|
+
newParts.push({ type: "text", text: `[Attached audio: ${filePath}]` });
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Keep text parts and anything else as-is
|
|
205
|
+
newParts.push(part);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return { ...m, content: newParts };
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
return { cleanMessages, mediaFiles };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function saveBase64ToTemp(base64Data: string, ext: string): string {
|
|
215
|
+
mkdirSync(MEDIA_TMP_DIR, { recursive: true });
|
|
216
|
+
const fileName = `media-${randomBytes(8).toString("hex")}.${ext}`;
|
|
217
|
+
const filePath = join(MEDIA_TMP_DIR, fileName);
|
|
218
|
+
writeFileSync(filePath, Buffer.from(base64Data, "base64"));
|
|
219
|
+
return filePath;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Schedule deletion of temp media files after a delay. */
|
|
223
|
+
export function cleanupMediaFiles(files: MediaFile[], delayMs = 60_000): void {
|
|
224
|
+
if (files.length === 0) return;
|
|
225
|
+
setTimeout(() => {
|
|
226
|
+
for (const f of files) {
|
|
227
|
+
try { unlinkSync(f.path); } catch { /* already deleted */ }
|
|
228
|
+
}
|
|
229
|
+
}, delayMs);
|
|
230
|
+
}
|
|
231
|
+
|
|
105
232
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
106
233
|
// Minimal environment for spawned subprocesses
|
|
107
234
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
@@ -263,13 +390,21 @@ export async function runGemini(
|
|
|
263
390
|
prompt: string,
|
|
264
391
|
modelId: string,
|
|
265
392
|
timeoutMs: number,
|
|
266
|
-
workdir?: string
|
|
393
|
+
workdir?: string,
|
|
394
|
+
opts?: { tools?: ToolDefinition[] }
|
|
267
395
|
): Promise<string> {
|
|
268
396
|
const model = stripPrefix(modelId);
|
|
269
397
|
// -p "" = headless mode trigger; actual prompt arrives via stdin
|
|
270
|
-
|
|
398
|
+
// --approval-mode yolo: auto-approve all tool executions, never ask questions
|
|
399
|
+
const args = ["-m", model, "-p", "", "--approval-mode", "yolo"];
|
|
271
400
|
const cwd = workdir ?? tmpdir();
|
|
272
|
-
|
|
401
|
+
|
|
402
|
+
// When tools are present, prepend tool instructions to prompt
|
|
403
|
+
const effectivePrompt = opts?.tools?.length
|
|
404
|
+
? buildToolPromptBlock(opts.tools) + "\n\n" + prompt
|
|
405
|
+
: prompt;
|
|
406
|
+
|
|
407
|
+
const result = await runCli("gemini", args, effectivePrompt, timeoutMs, { cwd });
|
|
273
408
|
|
|
274
409
|
// Filter out [WARN] lines from stderr (Gemini emits noisy permission warnings)
|
|
275
410
|
const cleanStderr = result.stderr
|
|
@@ -298,23 +433,31 @@ export async function runClaude(
|
|
|
298
433
|
prompt: string,
|
|
299
434
|
modelId: string,
|
|
300
435
|
timeoutMs: number,
|
|
301
|
-
workdir?: string
|
|
436
|
+
workdir?: string,
|
|
437
|
+
opts?: { tools?: ToolDefinition[] }
|
|
302
438
|
): Promise<string> {
|
|
303
439
|
// Proactively refresh OAuth token if it's about to expire (< 5 min remaining).
|
|
304
440
|
// No-op for API-key users.
|
|
305
441
|
await ensureClaudeToken();
|
|
306
442
|
|
|
307
443
|
const model = stripPrefix(modelId);
|
|
308
|
-
|
|
444
|
+
// Always use bypassPermissions to ensure fully autonomous execution (never asks questions).
|
|
445
|
+
// Use text output for all cases — JSON schema is unreliable with Claude Code's system prompt.
|
|
446
|
+
const args: string[] = [
|
|
309
447
|
"-p",
|
|
310
448
|
"--output-format", "text",
|
|
311
|
-
"--permission-mode", "
|
|
312
|
-
"--
|
|
449
|
+
"--permission-mode", "bypassPermissions",
|
|
450
|
+
"--dangerously-skip-permissions",
|
|
313
451
|
"--model", model,
|
|
314
452
|
];
|
|
315
453
|
|
|
454
|
+
// When tools are present, prepend tool instructions to prompt
|
|
455
|
+
const effectivePrompt = opts?.tools?.length
|
|
456
|
+
? buildToolPromptBlock(opts.tools) + "\n\n" + prompt
|
|
457
|
+
: prompt;
|
|
458
|
+
|
|
316
459
|
const cwd = workdir ?? homedir();
|
|
317
|
-
const result = await runCli("claude", args,
|
|
460
|
+
const result = await runCli("claude", args, effectivePrompt, timeoutMs, { cwd });
|
|
318
461
|
|
|
319
462
|
// On 401: attempt one token refresh + retry before giving up.
|
|
320
463
|
if (result.exitCode !== 0 && result.stdout.length === 0) {
|
|
@@ -322,7 +465,7 @@ export async function runClaude(
|
|
|
322
465
|
if (stderr.includes("401") || stderr.includes("Invalid authentication credentials") || stderr.includes("authentication_error")) {
|
|
323
466
|
// Refresh and retry once
|
|
324
467
|
await refreshClaudeToken();
|
|
325
|
-
const retry = await runCli("claude", args,
|
|
468
|
+
const retry = await runCli("claude", args, effectivePrompt, timeoutMs, { cwd });
|
|
326
469
|
if (retry.exitCode !== 0 && retry.stdout.length === 0) {
|
|
327
470
|
const retryStderr = retry.stderr || "(no output)";
|
|
328
471
|
if (retryStderr.includes("401") || retryStderr.includes("authentication_error") || retryStderr.includes("Invalid authentication credentials")) {
|
|
@@ -364,16 +507,32 @@ export async function runCodex(
|
|
|
364
507
|
prompt: string,
|
|
365
508
|
modelId: string,
|
|
366
509
|
timeoutMs: number,
|
|
367
|
-
workdir?: string
|
|
510
|
+
workdir?: string,
|
|
511
|
+
opts?: { tools?: ToolDefinition[]; mediaFiles?: MediaFile[] }
|
|
368
512
|
): Promise<string> {
|
|
369
513
|
const model = stripPrefix(modelId);
|
|
370
514
|
const args = ["--model", model, "--quiet", "--full-auto"];
|
|
515
|
+
|
|
516
|
+
// Codex supports native image input via -i flag
|
|
517
|
+
if (opts?.mediaFiles?.length) {
|
|
518
|
+
for (const f of opts.mediaFiles) {
|
|
519
|
+
if (f.mimeType.startsWith("image/")) {
|
|
520
|
+
args.push("-i", f.path);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
371
525
|
const cwd = workdir ?? homedir();
|
|
372
526
|
|
|
373
527
|
// Codex requires a git repo in the working directory
|
|
374
528
|
ensureGitRepo(cwd);
|
|
375
529
|
|
|
376
|
-
|
|
530
|
+
// When tools are present, prepend tool instructions to prompt
|
|
531
|
+
const effectivePrompt = opts?.tools?.length
|
|
532
|
+
? buildToolPromptBlock(opts.tools) + "\n\n" + prompt
|
|
533
|
+
: prompt;
|
|
534
|
+
|
|
535
|
+
const result = await runCli("codex", args, effectivePrompt, timeoutMs, { cwd });
|
|
377
536
|
|
|
378
537
|
if (result.exitCode !== 0 && result.stdout.length === 0) {
|
|
379
538
|
throw new Error(`codex exited ${result.exitCode}: ${result.stderr || "(no output)"}`);
|
|
@@ -494,6 +653,16 @@ export interface RouteOptions {
|
|
|
494
653
|
* Overrides the per-runner default (tmpdir for gemini, homedir for others).
|
|
495
654
|
*/
|
|
496
655
|
workdir?: string;
|
|
656
|
+
/**
|
|
657
|
+
* OpenAI tool definitions. When present, tool instructions are injected
|
|
658
|
+
* into the prompt and structured tool_call responses are parsed.
|
|
659
|
+
*/
|
|
660
|
+
tools?: ToolDefinition[];
|
|
661
|
+
/**
|
|
662
|
+
* Media files extracted from multimodal message content.
|
|
663
|
+
* Passed to CLIs that support native media input (e.g. codex -i).
|
|
664
|
+
*/
|
|
665
|
+
mediaFiles?: MediaFile[];
|
|
497
666
|
}
|
|
498
667
|
|
|
499
668
|
/**
|
|
@@ -504,6 +673,9 @@ export interface RouteOptions {
|
|
|
504
673
|
* opencode/<id> → opencode CLI
|
|
505
674
|
* pi/<id> → pi CLI
|
|
506
675
|
*
|
|
676
|
+
* When `tools` are provided, tool instructions are injected into the prompt
|
|
677
|
+
* and the response is parsed for structured tool_calls.
|
|
678
|
+
*
|
|
507
679
|
* Enforces DEFAULT_ALLOWED_CLI_MODELS by default (T-103).
|
|
508
680
|
* Pass `allowedModels: null` to skip the allowlist check.
|
|
509
681
|
*/
|
|
@@ -512,8 +684,9 @@ export async function routeToCliRunner(
|
|
|
512
684
|
messages: ChatMessage[],
|
|
513
685
|
timeoutMs: number,
|
|
514
686
|
opts: RouteOptions = {}
|
|
515
|
-
): Promise<
|
|
687
|
+
): Promise<CliToolResult> {
|
|
516
688
|
const prompt = formatPrompt(messages);
|
|
689
|
+
const hasTools = !!(opts.tools?.length);
|
|
517
690
|
|
|
518
691
|
// Strip "vllm/" prefix if present — OpenClaw sends the full provider path
|
|
519
692
|
// (e.g. "vllm/cli-claude/claude-sonnet-4-6") but the router only needs the
|
|
@@ -535,15 +708,23 @@ export async function routeToCliRunner(
|
|
|
535
708
|
// Resolve aliases (e.g. gemini-3-pro → gemini-3-pro-preview) after allowlist check
|
|
536
709
|
const resolved = normalizeModelAlias(normalized);
|
|
537
710
|
|
|
538
|
-
|
|
539
|
-
if (resolved.startsWith("cli-
|
|
540
|
-
if (resolved.startsWith("
|
|
541
|
-
if (resolved.startsWith("
|
|
542
|
-
if (resolved.startsWith("
|
|
543
|
-
|
|
544
|
-
throw new Error(
|
|
711
|
+
let rawText: string;
|
|
712
|
+
if (resolved.startsWith("cli-gemini/")) rawText = await runGemini(prompt, resolved, timeoutMs, opts.workdir, { tools: opts.tools });
|
|
713
|
+
else if (resolved.startsWith("cli-claude/")) rawText = await runClaude(prompt, resolved, timeoutMs, opts.workdir, { tools: opts.tools });
|
|
714
|
+
else if (resolved.startsWith("openai-codex/")) rawText = await runCodex(prompt, resolved, timeoutMs, opts.workdir, { tools: opts.tools, mediaFiles: opts.mediaFiles });
|
|
715
|
+
else if (resolved.startsWith("opencode/")) rawText = await runOpenCode(prompt, resolved, timeoutMs, opts.workdir);
|
|
716
|
+
else if (resolved.startsWith("pi/")) rawText = await runPi(prompt, resolved, timeoutMs, opts.workdir);
|
|
717
|
+
else throw new Error(
|
|
545
718
|
`Unknown CLI bridge model: "${model}". Use "vllm/cli-gemini/<model>", "vllm/cli-claude/<model>", "openai-codex/<model>", "opencode/<model>", or "pi/<model>".`
|
|
546
719
|
);
|
|
720
|
+
|
|
721
|
+
// When tools were provided, try to parse structured tool_calls from the response
|
|
722
|
+
if (hasTools) {
|
|
723
|
+
return parseToolCallResponse(rawText);
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
// No tools — wrap plain text
|
|
727
|
+
return { content: rawText };
|
|
547
728
|
}
|
|
548
729
|
|
|
549
730
|
// ──────────────────────────────────────────────────────────────────────────────
|
package/src/metrics.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* metrics.ts
|
|
3
|
+
*
|
|
4
|
+
* In-memory metrics collector for the CLI bridge proxy.
|
|
5
|
+
* Tracks request counts, errors, latency, and token usage per model.
|
|
6
|
+
* All operations are O(1) — cannot block the event loop.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export interface ModelMetrics {
|
|
10
|
+
model: string;
|
|
11
|
+
requests: number;
|
|
12
|
+
errors: number;
|
|
13
|
+
totalLatencyMs: number;
|
|
14
|
+
promptTokens: number;
|
|
15
|
+
completionTokens: number;
|
|
16
|
+
lastRequestAt: number | null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface MetricsSnapshot {
|
|
20
|
+
startedAt: number;
|
|
21
|
+
totalRequests: number;
|
|
22
|
+
totalErrors: number;
|
|
23
|
+
models: ModelMetrics[]; // sorted by requests desc
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
class MetricsCollector {
|
|
27
|
+
private startedAt = Date.now();
|
|
28
|
+
private data = new Map<string, ModelMetrics>();
|
|
29
|
+
|
|
30
|
+
recordRequest(
|
|
31
|
+
model: string,
|
|
32
|
+
durationMs: number,
|
|
33
|
+
success: boolean,
|
|
34
|
+
promptTokens?: number,
|
|
35
|
+
completionTokens?: number,
|
|
36
|
+
): void {
|
|
37
|
+
let entry = this.data.get(model);
|
|
38
|
+
if (!entry) {
|
|
39
|
+
entry = {
|
|
40
|
+
model,
|
|
41
|
+
requests: 0,
|
|
42
|
+
errors: 0,
|
|
43
|
+
totalLatencyMs: 0,
|
|
44
|
+
promptTokens: 0,
|
|
45
|
+
completionTokens: 0,
|
|
46
|
+
lastRequestAt: null,
|
|
47
|
+
};
|
|
48
|
+
this.data.set(model, entry);
|
|
49
|
+
}
|
|
50
|
+
entry.requests++;
|
|
51
|
+
if (!success) entry.errors++;
|
|
52
|
+
entry.totalLatencyMs += durationMs;
|
|
53
|
+
if (promptTokens) entry.promptTokens += promptTokens;
|
|
54
|
+
if (completionTokens) entry.completionTokens += completionTokens;
|
|
55
|
+
entry.lastRequestAt = Date.now();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
getMetrics(): MetricsSnapshot {
|
|
59
|
+
let totalRequests = 0;
|
|
60
|
+
let totalErrors = 0;
|
|
61
|
+
const models: ModelMetrics[] = [];
|
|
62
|
+
|
|
63
|
+
for (const entry of this.data.values()) {
|
|
64
|
+
totalRequests += entry.requests;
|
|
65
|
+
totalErrors += entry.errors;
|
|
66
|
+
models.push({ ...entry });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
models.sort((a, b) => b.requests - a.requests);
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
startedAt: this.startedAt,
|
|
73
|
+
totalRequests,
|
|
74
|
+
totalErrors,
|
|
75
|
+
models,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
reset(): void {
|
|
80
|
+
this.startedAt = Date.now();
|
|
81
|
+
this.data.clear();
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export const metrics = new MetricsCollector();
|