@elvatis_com/openclaw-cli-bridge-elvatis 2.2.2 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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