@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.
@@ -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.2.2 | ✅ | ✅ | ✅ Stable |
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.2.2 | ✅ Pushed to main |
19
- | npm | 2.2.2 | Published (via CI) |
20
- | ClawHub | 2.2.2 | Published (via CI) |
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-018 | Fix vllm apiKey corruption (401) + harden config-patcher | 2.2.2 |
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 |
@@ -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 | 18 |
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 |
@@ -1,9 +1,9 @@
1
1
  # STATUS — openclaw-cli-bridge-elvatis
2
2
 
3
- ## Current Version: 2.2.2
3
+ ## Current Version: 2.3.0
4
4
 
5
- - **npm:** @elvatis_com/openclaw-cli-bridge-elvatis@2.2.2
6
- - **ClawHub:** openclaw-cli-bridge-elvatis@2.2.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/*`): plain text completions only NO tool/function call support
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 Limitation
29
- CLI models explicitly reject tool/function call requests (HTTP 400):
30
- ```
31
- Model cli-claude/claude-opus-4-6 does not support tool/function calls.
32
- Use a native API model (e.g. github-copilot/gpt-5-mini) for agents that need tools.
33
- ```
34
- This is by design CLI tools output plain text only.
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.2.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
 
@@ -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.2.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.2.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 (OpenAI multimodal format)
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 ContentPart[])
91
- .filter((c) => c?.type === "text" && typeof c.text === "string")
92
- .map((c) => c.text!)
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
- const args = ["-m", model, "-p", ""];
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
- const result = await runCli("gemini", args, prompt, timeoutMs, { cwd });
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
- const args = [
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", "plan",
312
- "--tools", "",
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, prompt, timeoutMs, { cwd });
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, prompt, timeoutMs, { cwd });
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
- const result = await runCli("codex", args, prompt, timeoutMs, { cwd });
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<string> {
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
- if (resolved.startsWith("cli-gemini/")) return runGemini(prompt, resolved, timeoutMs, opts.workdir);
539
- if (resolved.startsWith("cli-claude/")) return runClaude(prompt, resolved, timeoutMs, opts.workdir);
540
- if (resolved.startsWith("openai-codex/")) return runCodex(prompt, resolved, timeoutMs, opts.workdir);
541
- if (resolved.startsWith("opencode/")) return runOpenCode(prompt, resolved, timeoutMs, opts.workdir);
542
- if (resolved.startsWith("pi/")) return runPi(prompt, resolved, timeoutMs, opts.workdir);
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();