@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.
- package/.ai/handoff/DASHBOARD.md +6 -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/package.json +1 -1
- package/src/cli-runner.ts +206 -25
- package/src/proxy-server.ts +66 -39
- 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.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.
|
|
19
|
-
| npm | 2.
|
|
20
|
-
| ClawHub | 2.
|
|
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-
|
|
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 |
|
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@elvatis_com/openclaw-cli-bridge-elvatis",
|
|
3
|
-
"version": "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 (
|
|
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/proxy-server.ts
CHANGED
|
@@ -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: !
|
|
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?:
|
|
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
|
|
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
|
-
//
|
|
310
|
-
|
|
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} · ${
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
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
|
-
|
|
662
|
-
|
|
663
|
-
|
|
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
|
|
682
|
-
finish_reason:
|
|
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).
|
|
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).
|
|
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).
|
|
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).
|
|
223
|
+
expect(result).toEqual({ content: "routed output" });
|
|
224
224
|
expect(mockSpawn).toHaveBeenCalledWith("pi", expect.any(Array), expect.any(Object));
|
|
225
225
|
});
|
|
226
226
|
|
package/test/cli-runner.test.ts
CHANGED
|
@@ -123,7 +123,7 @@ describe("formatPrompt", () => {
|
|
|
123
123
|
expect(result).toContain("Part two");
|
|
124
124
|
});
|
|
125
125
|
|
|
126
|
-
it("
|
|
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).
|
|
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])", () => {
|
package/test/proxy-e2e.test.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
448
|
-
it("
|
|
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(
|
|
458
|
+
expect(res.status).toBe(200);
|
|
456
459
|
const body = JSON.parse(res.body);
|
|
457
|
-
expect(body.
|
|
460
|
+
expect(body.choices[0].message.content).toBeDefined();
|
|
458
461
|
});
|
|
459
462
|
|
|
460
|
-
it("
|
|
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(
|
|
470
|
+
expect(res.status).toBe(200);
|
|
468
471
|
const body = JSON.parse(res.body);
|
|
469
|
-
expect(body.
|
|
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
|
-
//
|
|
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===
|
|
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(
|
|
501
|
+
expect(m.capabilities.tools).toBe(true);
|
|
499
502
|
}
|
|
500
503
|
});
|
|
501
504
|
|
|
502
|
-
it("cli-claude models have capabilities.tools===
|
|
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(
|
|
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===
|
|
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(
|
|
531
|
+
expect(m.capabilities.tools).toBe(true);
|
|
529
532
|
}
|
|
530
533
|
});
|
|
531
534
|
|
|
532
|
-
it("opencode models have capabilities.tools===
|
|
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(
|
|
541
|
+
expect(m.capabilities.tools).toBe(true);
|
|
539
542
|
}
|
|
540
543
|
});
|
|
541
544
|
|
|
542
|
-
it("pi models have capabilities.tools===
|
|
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(
|
|
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("
|
|
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(
|
|
595
|
-
expect(JSON.parse(res.body).
|
|
597
|
+
expect(res.status).toBe(200);
|
|
598
|
+
expect(JSON.parse(res.body).choices[0].message.content).toBeDefined();
|
|
596
599
|
});
|
|
597
600
|
|
|
598
|
-
it("
|
|
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(
|
|
605
|
-
expect(JSON.parse(res.body).
|
|
607
|
+
expect(res.status).toBe(200);
|
|
608
|
+
expect(JSON.parse(res.body).choices[0].message.content).toBeDefined();
|
|
606
609
|
});
|
|
607
610
|
|
|
608
|
-
it("
|
|
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(
|
|
615
|
-
expect(JSON.parse(res.body).
|
|
617
|
+
expect(res.status).toBe(200);
|
|
618
|
+
expect(JSON.parse(res.body).choices[0].message.content).toBeDefined();
|
|
616
619
|
});
|
|
617
620
|
});
|
|
618
621
|
|