@elvatis_com/openclaw-cli-bridge-elvatis 2.2.2 → 2.3.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.3.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.3.0 | ✅ Pushed to main |
19
+ | npm | 2.3.0 | ✅ Published (via CI) |
20
+ | ClawHub | 2.3.0 | ✅ Published (via CI) |
21
21
  <!-- /SECTION: release_state -->
22
22
 
23
23
  <!-- SECTION: open_tasks -->
@@ -31,7 +31,8 @@ _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-019 | Full-featured CLI bridge: tool calls + multimodal + autonomous execution | 2.3.0 |
35
+ | T-018 | Fix vllm apiKey corruption (401) + harden config-patcher | 2.2.1 |
35
36
  | T-017 | Fix log spam, restart loops, CLI blocking | 2.2.0 |
36
37
  | T-016 | Issue #2: Codex auth auto-import into agent auth store | 2.1.0 |
37
38
  | 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
 
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.3.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
  // ──────────────────────────────────────────────────────────────────────────────
@@ -10,7 +10,7 @@
10
10
 
11
11
  import http from "node:http";
12
12
  import { randomBytes } from "node:crypto";
13
- import { type ChatMessage, routeToCliRunner } from "./cli-runner.js";
13
+ import { type ChatMessage, type CliToolResult, type ToolDefinition, routeToCliRunner, extractMultimodalParts, cleanupMediaFiles } from "./cli-runner.js";
14
14
  import { scheduleTokenRefresh, setAuthLogger, stopTokenRefresh } from "./claude-auth.js";
15
15
  import { grokComplete, grokCompleteStream, type ChatMessage as GrokChatMessage } from "./grok-client.js";
16
16
  import { geminiComplete, geminiCompleteStream, type ChatMessage as GeminiBrowserChatMessage } from "./gemini-browser.js";
@@ -258,9 +258,8 @@ async function handleRequest(
258
258
  object: "model",
259
259
  created: now,
260
260
  owned_by: "openclaw-cli-bridge",
261
- // CLI-proxy models stream plain text — no tool/function call support
262
261
  capabilities: {
263
- tools: !(m.id.startsWith("cli-gemini/") || m.id.startsWith("cli-claude/") || m.id.startsWith("openai-codex/") || m.id.startsWith("opencode/") || m.id.startsWith("pi/") || m.id.startsWith("local-bitnet/")),
262
+ tools: !m.id.startsWith("local-bitnet/"), // all CLI models support tools via prompt injection; only bitnet is text-only
264
263
  },
265
264
  })),
266
265
  })
@@ -296,9 +295,10 @@ async function handleRequest(
296
295
  return;
297
296
  }
298
297
 
299
- const { model, messages, stream = false } = parsed as { model: string; messages: ChatMessage[]; stream?: boolean; tools?: unknown; workdir?: string };
298
+ const { model, messages, stream = false } = parsed as { model: string; messages: ChatMessage[]; stream?: boolean; tools?: ToolDefinition[]; workdir?: string };
300
299
  const workdir = (parsed as { workdir?: string }).workdir;
301
- const hasTools = Array.isArray((parsed as { tools?: unknown }).tools) && (parsed as { tools?: unknown[] }).tools!.length > 0;
300
+ const tools = (parsed as { tools?: ToolDefinition[] }).tools;
301
+ const hasTools = Array.isArray(tools) && tools.length > 0;
302
302
 
303
303
  if (!model || !messages?.length) {
304
304
  res.writeHead(400, { "Content-Type": "application/json" });
@@ -306,23 +306,10 @@ async function handleRequest(
306
306
  return;
307
307
  }
308
308
 
309
- // CLI-proxy models (cli-gemini/*, cli-claude/*) are plain text completions
310
- // they cannot process tool/function call schemas. Return a clear 400 so
311
- // OpenClaw can surface a meaningful error instead of getting a garbled response.
312
- const isCliModel = model.startsWith("cli-gemini/") || model.startsWith("cli-claude/") || model.startsWith("openai-codex/") || model.startsWith("opencode/") || model.startsWith("pi/"); // local-bitnet/* exempt: llama-server silently ignores tools
313
- if (hasTools && isCliModel) {
314
- res.writeHead(400, { "Content-Type": "application/json" });
315
- res.end(JSON.stringify({
316
- error: {
317
- message: `Model ${model} does not support tool/function calls. Use a native API model (e.g. github-copilot/gpt-5-mini) for agents that need tools.`,
318
- type: "invalid_request_error",
319
- code: "tools_not_supported",
320
- }
321
- }));
322
- return;
323
- }
309
+ // Extract multimodal content (images, audio) from messages temp files
310
+ const { cleanMessages, mediaFiles } = extractMultimodalParts(messages);
324
311
 
325
- opts.log(`[cli-bridge] ${model} · ${messages.length} msg(s) · stream=${stream}${hasTools ? " · tools=unsupported→rejected" : ""}`);
312
+ opts.log(`[cli-bridge] ${model} · ${cleanMessages.length} msg(s) · stream=${stream}${hasTools ? ` · tools=${tools!.length}` : ""}${mediaFiles.length ? ` · media=${mediaFiles.length}` : ""}`);
326
313
 
327
314
  const id = `chatcmpl-cli-${randomBytes(6).toString("hex")}`;
328
315
  const created = Math.floor(Date.now() / 1000);
@@ -612,11 +599,12 @@ async function handleRequest(
612
599
  }
613
600
  // ─────────────────────────────────────────────────────────────────────────
614
601
 
615
- // ── CLI runner routing (Gemini / Claude Code) ─────────────────────────────
616
- let content: string;
602
+ // ── CLI runner routing (Gemini / Claude Code / Codex) ──────────────────────
603
+ let result: CliToolResult;
617
604
  let usedModel = model;
605
+ const routeOpts = { workdir, tools: hasTools ? tools : undefined, mediaFiles: mediaFiles.length ? mediaFiles : undefined };
618
606
  try {
619
- content = await routeToCliRunner(model, messages, opts.timeoutMs ?? 120_000, { workdir });
607
+ result = await routeToCliRunner(model, cleanMessages, opts.timeoutMs ?? 120_000, routeOpts);
620
608
  } catch (err) {
621
609
  const msg = (err as Error).message;
622
610
  // ── Model fallback: retry once with a lighter model if configured ────
@@ -624,7 +612,7 @@ async function handleRequest(
624
612
  if (fallbackModel) {
625
613
  opts.warn(`[cli-bridge] ${model} failed (${msg}), falling back to ${fallbackModel}`);
626
614
  try {
627
- content = await routeToCliRunner(fallbackModel, messages, opts.timeoutMs ?? 120_000, { workdir });
615
+ result = await routeToCliRunner(fallbackModel, cleanMessages, opts.timeoutMs ?? 120_000, routeOpts);
628
616
  usedModel = fallbackModel;
629
617
  opts.log(`[cli-bridge] fallback to ${fallbackModel} succeeded`);
630
618
  } catch (fallbackErr) {
@@ -640,8 +628,14 @@ async function handleRequest(
640
628
  res.end(JSON.stringify({ error: { message: msg, type: "cli_error" } }));
641
629
  return;
642
630
  }
631
+ } finally {
632
+ // Clean up temp media files after response
633
+ cleanupMediaFiles(mediaFiles);
643
634
  }
644
635
 
636
+ const hasToolCalls = !!(result.tool_calls?.length);
637
+ const finishReason = hasToolCalls ? "tool_calls" : "stop";
638
+
645
639
  if (stream) {
646
640
  res.writeHead(200, {
647
641
  "Content-Type": "text/event-stream",
@@ -650,26 +644,59 @@ async function handleRequest(
650
644
  ...corsHeaders(),
651
645
  });
652
646
 
653
- // Role chunk
654
- sendSseChunk(res, { id, created, model: usedModel, delta: { role: "assistant" }, finish_reason: null });
655
-
656
- // Content in chunks (~50 chars each for natural feel)
657
- const chunkSize = 50;
658
- for (let i = 0; i < content.length; i += chunkSize) {
647
+ if (hasToolCalls) {
648
+ // Stream tool_calls in OpenAI SSE format
649
+ const toolCalls = result.tool_calls!;
650
+ // Role chunk with all tool_calls (name + empty arguments)
659
651
  sendSseChunk(res, {
660
- id,
661
- created,
662
- model: usedModel,
663
- delta: { content: content.slice(i, i + chunkSize) },
652
+ id, created, model: usedModel,
653
+ delta: {
654
+ role: "assistant",
655
+ tool_calls: toolCalls.map((tc, idx) => ({
656
+ index: idx, id: tc.id, type: "function",
657
+ function: { name: tc.function.name, arguments: "" },
658
+ })),
659
+ },
664
660
  finish_reason: null,
665
661
  });
662
+ // Arguments chunks (one per tool call)
663
+ for (let idx = 0; idx < toolCalls.length; idx++) {
664
+ sendSseChunk(res, {
665
+ id, created, model: usedModel,
666
+ delta: {
667
+ tool_calls: [{ index: idx, function: { arguments: toolCalls[idx].function.arguments } }],
668
+ },
669
+ finish_reason: null,
670
+ });
671
+ }
672
+ // Stop chunk
673
+ sendSseChunk(res, { id, created, model: usedModel, delta: {}, finish_reason: "tool_calls" });
674
+ } else {
675
+ // Standard text streaming
676
+ sendSseChunk(res, { id, created, model: usedModel, delta: { role: "assistant" }, finish_reason: null });
677
+ const content = result.content ?? "";
678
+ const chunkSize = 50;
679
+ for (let i = 0; i < content.length; i += chunkSize) {
680
+ sendSseChunk(res, {
681
+ id, created, model: usedModel,
682
+ delta: { content: content.slice(i, i + chunkSize) },
683
+ finish_reason: null,
684
+ });
685
+ }
686
+ sendSseChunk(res, { id, created, model: usedModel, delta: {}, finish_reason: "stop" });
666
687
  }
667
688
 
668
- // Stop chunk
669
- sendSseChunk(res, { id, created, model: usedModel, delta: {}, finish_reason: "stop" });
670
689
  res.write("data: [DONE]\n\n");
671
690
  res.end();
672
691
  } else {
692
+ const message: Record<string, unknown> = { role: "assistant" };
693
+ if (hasToolCalls) {
694
+ message.content = null;
695
+ message.tool_calls = result.tool_calls;
696
+ } else {
697
+ message.content = result.content;
698
+ }
699
+
673
700
  const response = {
674
701
  id,
675
702
  object: "chat.completion",
@@ -678,8 +705,8 @@ async function handleRequest(
678
705
  choices: [
679
706
  {
680
707
  index: 0,
681
- message: { role: "assistant", content },
682
- finish_reason: "stop",
708
+ message,
709
+ finish_reason: finishReason,
683
710
  },
684
711
  ],
685
712
  usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
@@ -0,0 +1,269 @@
1
+ /**
2
+ * tool-protocol.ts
3
+ *
4
+ * Translates between the OpenAI tool-calling protocol and CLI text I/O.
5
+ *
6
+ * - buildToolPromptBlock(): injects tool definitions + instructions into the prompt
7
+ * - buildToolCallJsonSchema(): returns JSON schema for Claude's --json-schema flag
8
+ * - parseToolCallResponse(): extracts tool_calls from CLI output text/JSON
9
+ * - generateCallId(): unique call IDs for tool_calls
10
+ */
11
+
12
+ import { randomBytes } from "node:crypto";
13
+
14
+ // ──────────────────────────────────────────────────────────────────────────────
15
+ // Types
16
+ // ──────────────────────────────────────────────────────────────────────────────
17
+
18
+ export interface ToolDefinition {
19
+ type: "function";
20
+ function: {
21
+ name: string;
22
+ description: string;
23
+ parameters: Record<string, unknown>;
24
+ };
25
+ }
26
+
27
+ export interface ToolCall {
28
+ id: string;
29
+ type: "function";
30
+ function: {
31
+ name: string;
32
+ arguments: string; // JSON-encoded arguments
33
+ };
34
+ }
35
+
36
+ export interface CliToolResult {
37
+ content: string | null;
38
+ tool_calls?: ToolCall[];
39
+ }
40
+
41
+ // ──────────────────────────────────────────────────────────────────────────────
42
+ // Prompt building
43
+ // ──────────────────────────────────────────────────────────────────────────────
44
+
45
+ /**
46
+ * Build a text block describing available tools and response format instructions.
47
+ * This block is prepended to the system message (or added as a new system message).
48
+ */
49
+ export function buildToolPromptBlock(tools: ToolDefinition[]): string {
50
+ const toolDescriptions = tools
51
+ .map((t) => {
52
+ const fn = t.function;
53
+ const params = JSON.stringify(fn.parameters);
54
+ return `- name: ${fn.name}\n description: ${fn.description}\n parameters: ${params}`;
55
+ })
56
+ .join("\n");
57
+
58
+ return [
59
+ "You have access to the following tools.",
60
+ "",
61
+ "IMPORTANT: You must respond with ONLY valid JSON in one of these two formats:",
62
+ "",
63
+ 'To call one or more tools, respond with ONLY:',
64
+ '{"tool_calls":[{"name":"<tool_name>","arguments":{<parameters as JSON object>}}]}',
65
+ "",
66
+ 'To respond with text (no tool call needed), respond with ONLY:',
67
+ '{"content":"<your text response>"}',
68
+ "",
69
+ "Do NOT include any text outside the JSON. Do NOT wrap in markdown code blocks.",
70
+ "",
71
+ "Available tools:",
72
+ toolDescriptions,
73
+ ].join("\n");
74
+ }
75
+
76
+ // ──────────────────────────────────────────────────────────────────────────────
77
+ // JSON Schema for Claude's --json-schema flag
78
+ // ──────────────────────────────────────────────────────────────────────────────
79
+
80
+ /**
81
+ * Returns a JSON schema that constrains Claude's output to either:
82
+ * - { "content": "text response" }
83
+ * - { "tool_calls": [{ "name": "...", "arguments": { ... } }] }
84
+ */
85
+ export function buildToolCallJsonSchema(): object {
86
+ return {
87
+ type: "object",
88
+ properties: {
89
+ content: { type: "string" },
90
+ tool_calls: {
91
+ type: "array",
92
+ items: {
93
+ type: "object",
94
+ properties: {
95
+ name: { type: "string" },
96
+ arguments: { type: "object" },
97
+ },
98
+ required: ["name", "arguments"],
99
+ },
100
+ },
101
+ },
102
+ additionalProperties: false,
103
+ };
104
+ }
105
+
106
+ // ──────────────────────────────────────────────────────────────────────────────
107
+ // Response parsing
108
+ // ──────────────────────────────────────────────────────────────────────────────
109
+
110
+ /**
111
+ * Parse CLI output text into a CliToolResult.
112
+ *
113
+ * Tries to extract JSON from the text. If valid JSON with tool_calls is found,
114
+ * returns structured tool calls. Otherwise returns the text as content.
115
+ *
116
+ * Never throws — always returns a valid result.
117
+ */
118
+ export function parseToolCallResponse(text: string): CliToolResult {
119
+ const trimmed = text.trim();
120
+
121
+ // Check for Claude's --output-format json wrapper FIRST.
122
+ // Claude returns: { "type": "result", "result": "..." }
123
+ // The inner `result` field contains the actual model output (with tool_calls or content).
124
+ const claudeResult = tryExtractClaudeJsonResult(trimmed);
125
+ if (claudeResult) {
126
+ const inner = tryParseJson(claudeResult);
127
+ if (inner) return normalizeResult(inner);
128
+ // Claude result is plain text
129
+ return { content: claudeResult };
130
+ }
131
+
132
+ // Try direct JSON parse (for non-Claude outputs)
133
+ const parsed = tryParseJson(trimmed);
134
+ if (parsed) return normalizeResult(parsed);
135
+
136
+ // Try extracting JSON from markdown code blocks: ```json ... ```
137
+ const codeBlock = tryExtractCodeBlock(trimmed);
138
+ if (codeBlock) {
139
+ const inner = tryParseJson(codeBlock);
140
+ if (inner) return normalizeResult(inner);
141
+ }
142
+
143
+ // Try finding a JSON object anywhere in the text
144
+ const embedded = tryExtractEmbeddedJson(trimmed);
145
+ if (embedded) {
146
+ const inner = tryParseJson(embedded);
147
+ if (inner) return normalizeResult(inner);
148
+ }
149
+
150
+ // Fallback: treat entire text as content
151
+ return { content: trimmed || null };
152
+ }
153
+
154
+ /**
155
+ * Normalize a parsed JSON object into a CliToolResult.
156
+ */
157
+ function normalizeResult(obj: Record<string, unknown>): CliToolResult {
158
+ // Check for tool_calls array
159
+ if (Array.isArray(obj.tool_calls) && obj.tool_calls.length > 0) {
160
+ const toolCalls: ToolCall[] = obj.tool_calls.map((tc: Record<string, unknown>) => ({
161
+ id: generateCallId(),
162
+ type: "function" as const,
163
+ function: {
164
+ name: String(tc.name ?? ""),
165
+ arguments: typeof tc.arguments === "string"
166
+ ? tc.arguments
167
+ : JSON.stringify(tc.arguments ?? {}),
168
+ },
169
+ }));
170
+ return { content: null, tool_calls: toolCalls };
171
+ }
172
+
173
+ // Check for content field
174
+ if (typeof obj.content === "string") {
175
+ return { content: obj.content };
176
+ }
177
+
178
+ // Unknown structure — serialize as content
179
+ return { content: JSON.stringify(obj) };
180
+ }
181
+
182
+ function tryParseJson(text: string): Record<string, unknown> | null {
183
+ try {
184
+ const obj = JSON.parse(text);
185
+ if (typeof obj === "object" && obj !== null && !Array.isArray(obj)) {
186
+ return obj as Record<string, unknown>;
187
+ }
188
+ return null;
189
+ } catch {
190
+ return null;
191
+ }
192
+ }
193
+
194
+ /**
195
+ * Extract the model output from Claude's JSON output wrapper.
196
+ * Claude CLI with --output-format json returns:
197
+ * { "type": "result", "result": "the model output",
198
+ * "structured_output": { "content": "..." }, ... }
199
+ *
200
+ * When --json-schema is used, the `result` field is the JSON-schema-constrained output.
201
+ * The `structured_output.content` field may also contain the raw output.
202
+ */
203
+ function tryExtractClaudeJsonResult(text: string): string | null {
204
+ try {
205
+ const obj = JSON.parse(text);
206
+ if (obj?.type === "result") {
207
+ // Prefer structured_output.content if available
208
+ if (typeof obj.structured_output?.content === "string") {
209
+ return obj.structured_output.content;
210
+ }
211
+ if (typeof obj.result === "string") {
212
+ return obj.result;
213
+ }
214
+ }
215
+ return null;
216
+ } catch {
217
+ return null;
218
+ }
219
+ }
220
+
221
+ /** Extract JSON from ```json ... ``` or ``` ... ``` code blocks. */
222
+ function tryExtractCodeBlock(text: string): string | null {
223
+ const match = text.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/);
224
+ return match?.[1]?.trim() ?? null;
225
+ }
226
+
227
+ /** Find the first { ... } JSON object in text (greedy, balanced braces). */
228
+ function tryExtractEmbeddedJson(text: string): string | null {
229
+ const start = text.indexOf("{");
230
+ if (start === -1) return null;
231
+
232
+ let depth = 0;
233
+ let inString = false;
234
+ let escaped = false;
235
+
236
+ for (let i = start; i < text.length; i++) {
237
+ const ch = text[i];
238
+ if (escaped) {
239
+ escaped = false;
240
+ continue;
241
+ }
242
+ if (ch === "\\") {
243
+ escaped = true;
244
+ continue;
245
+ }
246
+ if (ch === '"') {
247
+ inString = !inString;
248
+ continue;
249
+ }
250
+ if (inString) continue;
251
+ if (ch === "{") depth++;
252
+ if (ch === "}") {
253
+ depth--;
254
+ if (depth === 0) {
255
+ return text.slice(start, i + 1);
256
+ }
257
+ }
258
+ }
259
+ return null;
260
+ }
261
+
262
+ // ──────────────────────────────────────────────────────────────────────────────
263
+ // Utilities
264
+ // ──────────────────────────────────────────────────────────────────────────────
265
+
266
+ /** Generate a unique tool call ID: "call_" + 12 random hex characters. */
267
+ export function generateCallId(): string {
268
+ return "call_" + randomBytes(6).toString("hex");
269
+ }
@@ -189,7 +189,7 @@ describe("routeToCliRunner — new model prefixes", () => {
189
189
  [{ role: "user", content: "hi" }],
190
190
  5000
191
191
  );
192
- expect(result).toBe("routed output");
192
+ expect(result).toEqual({ content: "routed output" });
193
193
  expect(mockSpawn).toHaveBeenCalledWith("codex", expect.any(Array), expect.any(Object));
194
194
  });
195
195
 
@@ -200,7 +200,7 @@ describe("routeToCliRunner — new model prefixes", () => {
200
200
  5000,
201
201
  { allowedModels: null }
202
202
  );
203
- expect(result).toBe("routed output");
203
+ expect(result).toEqual({ content: "routed output" });
204
204
  expect(mockSpawn).toHaveBeenCalledWith("codex", expect.any(Array), expect.any(Object));
205
205
  });
206
206
 
@@ -210,7 +210,7 @@ describe("routeToCliRunner — new model prefixes", () => {
210
210
  [{ role: "user", content: "hi" }],
211
211
  5000
212
212
  );
213
- expect(result).toBe("routed output");
213
+ expect(result).toEqual({ content: "routed output" });
214
214
  expect(mockSpawn).toHaveBeenCalledWith("opencode", expect.any(Array), expect.any(Object));
215
215
  });
216
216
 
@@ -220,7 +220,7 @@ describe("routeToCliRunner — new model prefixes", () => {
220
220
  [{ role: "user", content: "hi" }],
221
221
  5000
222
222
  );
223
- expect(result).toBe("routed output");
223
+ expect(result).toEqual({ content: "routed output" });
224
224
  expect(mockSpawn).toHaveBeenCalledWith("pi", expect.any(Array), expect.any(Object));
225
225
  });
226
226
 
@@ -123,7 +123,7 @@ describe("formatPrompt", () => {
123
123
  expect(result).toContain("Part two");
124
124
  });
125
125
 
126
- it("ignores non-text ContentParts (e.g. image)", () => {
126
+ it("includes placeholder for non-text ContentParts (e.g. image)", () => {
127
127
  const result = formatPrompt([
128
128
  {
129
129
  role: "user",
@@ -133,7 +133,8 @@ describe("formatPrompt", () => {
133
133
  ],
134
134
  },
135
135
  ]);
136
- expect(result).toBe("describe this");
136
+ expect(result).toContain("describe this");
137
+ expect(result).toContain("[Attached image");
137
138
  });
138
139
 
139
140
  it("coerces plain object content to JSON string (not [object Object])", () => {
@@ -71,8 +71,11 @@ vi.mock("../src/cli-runner.js", async (importOriginal) => {
71
71
  if (!normalized.startsWith("cli-gemini/") && !normalized.startsWith("cli-claude/") && !normalized.startsWith("openai-codex/") && !normalized.startsWith("opencode/") && !normalized.startsWith("pi/")) {
72
72
  throw new Error(`Unknown CLI bridge model: "${model}"`);
73
73
  }
74
- return `Mock response from ${normalized}`;
74
+ // Returns CliToolResult (content + optional tool_calls)
75
+ return { content: `Mock response from ${normalized}` };
75
76
  }),
77
+ extractMultimodalParts: vi.fn((messages: unknown[]) => ({ cleanMessages: messages, mediaFiles: [] })),
78
+ cleanupMediaFiles: vi.fn(),
76
79
  };
77
80
  });
78
81
 
@@ -444,29 +447,29 @@ describe("Error handling", () => {
444
447
  // Tool/function call rejection for CLI-proxy models
445
448
  // ──────────────────────────────────────────────────────────────────────────────
446
449
 
447
- describe("Tool call rejection", () => {
448
- it("rejects tools for cli-gemini models with tools_not_supported", async () => {
450
+ describe("Tool call support", () => {
451
+ it("accepts tools for cli-gemini models (200)", async () => {
449
452
  const res = await json("/v1/chat/completions", {
450
453
  model: "cli-gemini/gemini-2.5-pro",
451
454
  messages: [{ role: "user", content: "hi" }],
452
455
  tools: [{ type: "function", function: { name: "test", parameters: {} } }],
453
456
  });
454
457
 
455
- expect(res.status).toBe(400);
458
+ expect(res.status).toBe(200);
456
459
  const body = JSON.parse(res.body);
457
- expect(body.error.code).toBe("tools_not_supported");
460
+ expect(body.choices[0].message.content).toBeDefined();
458
461
  });
459
462
 
460
- it("rejects tools for cli-claude models with tools_not_supported", async () => {
463
+ it("accepts tools for cli-claude models (200)", async () => {
461
464
  const res = await json("/v1/chat/completions", {
462
465
  model: "cli-claude/claude-sonnet-4-6",
463
466
  messages: [{ role: "user", content: "hi" }],
464
467
  tools: [{ type: "function", function: { name: "test", parameters: {} } }],
465
468
  });
466
469
 
467
- expect(res.status).toBe(400);
470
+ expect(res.status).toBe(200);
468
471
  const body = JSON.parse(res.body);
469
- expect(body.error.code).toBe("tools_not_supported");
472
+ expect(body.choices[0].message.content).toBeDefined();
470
473
  });
471
474
 
472
475
  it("does NOT reject tools for web-grok models (returns 503 no session)", async () => {
@@ -476,7 +479,7 @@ describe("Tool call rejection", () => {
476
479
  tools: [{ type: "function", function: { name: "test", parameters: {} } }],
477
480
  });
478
481
 
479
- // Should NOT be 400 tools_not_supported — reaches provider logic, gets 503 (no session)
482
+ // Reaches provider logic, gets 503 (no session)
480
483
  expect(res.status).not.toBe(400);
481
484
  expect(res.status).toBe(503);
482
485
  const body = JSON.parse(res.body);
@@ -489,23 +492,23 @@ describe("Tool call rejection", () => {
489
492
  // ──────────────────────────────────────────────────────────────────────────────
490
493
 
491
494
  describe("Model capabilities", () => {
492
- it("cli-gemini models have capabilities.tools===false", async () => {
495
+ it("cli-gemini models have capabilities.tools===true", async () => {
493
496
  const res = await fetch("/v1/models");
494
497
  const body = JSON.parse(res.body);
495
498
  const cliGeminiModels = body.data.filter((m: { id: string }) => m.id.startsWith("cli-gemini/"));
496
499
  expect(cliGeminiModels.length).toBeGreaterThan(0);
497
500
  for (const m of cliGeminiModels) {
498
- expect(m.capabilities.tools).toBe(false);
501
+ expect(m.capabilities.tools).toBe(true);
499
502
  }
500
503
  });
501
504
 
502
- it("cli-claude models have capabilities.tools===false", async () => {
505
+ it("cli-claude models have capabilities.tools===true", async () => {
503
506
  const res = await fetch("/v1/models");
504
507
  const body = JSON.parse(res.body);
505
508
  const cliClaudeModels = body.data.filter((m: { id: string }) => m.id.startsWith("cli-claude/"));
506
509
  expect(cliClaudeModels.length).toBeGreaterThan(0);
507
510
  for (const m of cliClaudeModels) {
508
- expect(m.capabilities.tools).toBe(false);
511
+ expect(m.capabilities.tools).toBe(true);
509
512
  }
510
513
  });
511
514
 
@@ -519,33 +522,33 @@ describe("Model capabilities", () => {
519
522
  }
520
523
  });
521
524
 
522
- it("openai-codex models have capabilities.tools===false", async () => {
525
+ it("openai-codex models have capabilities.tools===true", async () => {
523
526
  const res = await fetch("/v1/models");
524
527
  const body = JSON.parse(res.body);
525
528
  const codexModels = body.data.filter((m: { id: string }) => m.id.startsWith("openai-codex/"));
526
529
  expect(codexModels.length).toBeGreaterThan(0);
527
530
  for (const m of codexModels) {
528
- expect(m.capabilities.tools).toBe(false);
531
+ expect(m.capabilities.tools).toBe(true);
529
532
  }
530
533
  });
531
534
 
532
- it("opencode models have capabilities.tools===false", async () => {
535
+ it("opencode models have capabilities.tools===true", async () => {
533
536
  const res = await fetch("/v1/models");
534
537
  const body = JSON.parse(res.body);
535
538
  const ocModels = body.data.filter((m: { id: string }) => m.id.startsWith("opencode/"));
536
539
  expect(ocModels.length).toBeGreaterThan(0);
537
540
  for (const m of ocModels) {
538
- expect(m.capabilities.tools).toBe(false);
541
+ expect(m.capabilities.tools).toBe(true);
539
542
  }
540
543
  });
541
544
 
542
- it("pi models have capabilities.tools===false", async () => {
545
+ it("pi models have capabilities.tools===true", async () => {
543
546
  const res = await fetch("/v1/models");
544
547
  const body = JSON.parse(res.body);
545
548
  const piModels = body.data.filter((m: { id: string }) => m.id.startsWith("pi/"));
546
549
  expect(piModels.length).toBeGreaterThan(0);
547
550
  for (const m of piModels) {
548
- expect(m.capabilities.tools).toBe(false);
551
+ expect(m.capabilities.tools).toBe(true);
549
552
  }
550
553
  });
551
554
  });
@@ -585,34 +588,34 @@ describe("POST /v1/chat/completions — new model prefixes", () => {
585
588
  expect(body.choices[0].message.content).toBe("Mock response from pi/default");
586
589
  });
587
590
 
588
- it("rejects tools for openai-codex models", async () => {
591
+ it("accepts tools for openai-codex models", async () => {
589
592
  const res = await json("/v1/chat/completions", {
590
593
  model: "openai-codex/gpt-5.3-codex",
591
594
  messages: [{ role: "user", content: "hi" }],
592
595
  tools: [{ type: "function", function: { name: "test", parameters: {} } }],
593
596
  });
594
- expect(res.status).toBe(400);
595
- expect(JSON.parse(res.body).error.code).toBe("tools_not_supported");
597
+ expect(res.status).toBe(200);
598
+ expect(JSON.parse(res.body).choices[0].message.content).toBeDefined();
596
599
  });
597
600
 
598
- it("rejects tools for opencode models", async () => {
601
+ it("accepts tools for opencode models", async () => {
599
602
  const res = await json("/v1/chat/completions", {
600
603
  model: "opencode/default",
601
604
  messages: [{ role: "user", content: "hi" }],
602
605
  tools: [{ type: "function", function: { name: "test", parameters: {} } }],
603
606
  });
604
- expect(res.status).toBe(400);
605
- expect(JSON.parse(res.body).error.code).toBe("tools_not_supported");
607
+ expect(res.status).toBe(200);
608
+ expect(JSON.parse(res.body).choices[0].message.content).toBeDefined();
606
609
  });
607
610
 
608
- it("rejects tools for pi models", async () => {
611
+ it("accepts tools for pi models", async () => {
609
612
  const res = await json("/v1/chat/completions", {
610
613
  model: "pi/default",
611
614
  messages: [{ role: "user", content: "hi" }],
612
615
  tools: [{ type: "function", function: { name: "test", parameters: {} } }],
613
616
  });
614
- expect(res.status).toBe(400);
615
- expect(JSON.parse(res.body).error.code).toBe("tools_not_supported");
617
+ expect(res.status).toBe(200);
618
+ expect(JSON.parse(res.body).choices[0].message.content).toBeDefined();
616
619
  });
617
620
  });
618
621