pi-opencode-cli-bridge 0.2.13 → 0.2.15
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/README.md +5 -5
- package/SECURITY.md +1 -1
- package/package.json +3 -3
- package/src/cli.ts +87 -0
- package/src/index.ts +9 -1978
- package/src/json.ts +13 -0
- package/src/models.ts +351 -0
- package/src/process.ts +1 -1
- package/src/prompt.ts +181 -0
- package/src/sessions.ts +182 -0
- package/src/stream.ts +595 -0
- package/src/tool-calls.ts +556 -0
- package/src/types/models.ts +22 -0
- package/src/types/tool-calls.ts +15 -0
|
@@ -0,0 +1,556 @@
|
|
|
1
|
+
import { posix, win32 } from "node:path";
|
|
2
|
+
import { objectRecord } from "./json.js";
|
|
3
|
+
import type { ParsedToolCall, ToolCallParseResult } from "./types/tool-calls.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Map a blocked native OpenCode call onto the equivalent Pi tool, so Pi runs
|
|
7
|
+
* it under its own permissions. Calls whose semantics differ stay untranslated.
|
|
8
|
+
*/
|
|
9
|
+
export function translateNativeToolCall(
|
|
10
|
+
tool: string,
|
|
11
|
+
input: unknown,
|
|
12
|
+
allowedToolNames: Set<string>,
|
|
13
|
+
projectDirs: string[],
|
|
14
|
+
paths: typeof posix = process.platform === "win32" ? win32 : posix,
|
|
15
|
+
): ParsedToolCall | undefined {
|
|
16
|
+
const args = objectRecord(input);
|
|
17
|
+
if (!args) return undefined;
|
|
18
|
+
const text = (key: string) => typeof args[key] === "string" ? args[key] as string : undefined;
|
|
19
|
+
const count = (key: string) =>
|
|
20
|
+
typeof args[key] === "number" && args[key] > 0 ? args[key] as number : undefined;
|
|
21
|
+
// OpenCode resolves paths in its temporary project; Pi resolves them in the real one.
|
|
22
|
+
// Platform path rules also cover Windows slash and drive-letter case differences.
|
|
23
|
+
const path = (key: string) => {
|
|
24
|
+
const value = text(key);
|
|
25
|
+
if (value === undefined || !paths.isAbsolute(value)) return value;
|
|
26
|
+
for (const dir of projectDirs) {
|
|
27
|
+
const rel = paths.relative(dir, value);
|
|
28
|
+
if (rel !== ".." && !rel.startsWith(".." + paths.sep) && !paths.isAbsolute(rel)) return rel || ".";
|
|
29
|
+
}
|
|
30
|
+
return value;
|
|
31
|
+
};
|
|
32
|
+
const call = (name: string, entries: Record<string, unknown>): ParsedToolCall => ({
|
|
33
|
+
name,
|
|
34
|
+
arguments: Object.fromEntries(Object.entries(entries).filter(([, v]) => v !== undefined)),
|
|
35
|
+
});
|
|
36
|
+
const fileKey = text("filePath") !== undefined ? "filePath" : "path";
|
|
37
|
+
let result: ParsedToolCall | undefined;
|
|
38
|
+
switch (tool) {
|
|
39
|
+
case "bash":
|
|
40
|
+
case "shell": {
|
|
41
|
+
// shortcut: absolute temp-project paths inside the command are not rewritten; parse them if models rely on it.
|
|
42
|
+
const timeout = count("timeout");
|
|
43
|
+
if (text("command") === undefined ||
|
|
44
|
+
(args.workdir !== undefined && path("workdir") !== ".") ||
|
|
45
|
+
(tool === "shell" && (args.background === true || args.timeout === 0 ||
|
|
46
|
+
(args.cwd !== undefined && path("cwd") !== ".")))) break;
|
|
47
|
+
result = call("bash", { command: text("command"), timeout: timeout && Math.ceil(timeout / 1000) });
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
case "read":
|
|
51
|
+
if (text(fileKey) !== undefined) {
|
|
52
|
+
result = call("read", { path: path(fileKey), offset: count("offset"), limit: count("limit") });
|
|
53
|
+
}
|
|
54
|
+
break;
|
|
55
|
+
case "write":
|
|
56
|
+
if (text(fileKey) !== undefined && text("content") !== undefined) {
|
|
57
|
+
result = call("write", { path: path(fileKey), content: text("content") });
|
|
58
|
+
}
|
|
59
|
+
break;
|
|
60
|
+
case "edit":
|
|
61
|
+
// Pi edits need one unique, non-empty match: no replaceAll or create-by-edit.
|
|
62
|
+
if (text(fileKey) !== undefined && text("oldString") && text("newString") !== undefined &&
|
|
63
|
+
args.replaceAll !== true) {
|
|
64
|
+
result = call("edit", {
|
|
65
|
+
path: path(fileKey),
|
|
66
|
+
edits: [{ oldText: text("oldString"), newText: text("newString") }],
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
break;
|
|
70
|
+
case "grep":
|
|
71
|
+
if (text("pattern") !== undefined) {
|
|
72
|
+
result = call("grep", { pattern: text("pattern"), path: path("path"), glob: text("include") });
|
|
73
|
+
}
|
|
74
|
+
break;
|
|
75
|
+
case "glob":
|
|
76
|
+
if (text("pattern") !== undefined) result = call("find", { pattern: text("pattern"), path: path("path") });
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
return result && allowedToolNames.has(result.name) ? result : undefined;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const MARKER_OPEN = "<pi_tool_call>";
|
|
83
|
+
|
|
84
|
+
// Detects marker syntax anywhere in the response, not just at the start, so
|
|
85
|
+
// prose-then-marker and marker-then-prose responses are treated the same
|
|
86
|
+
// way: either both are rejected as malformed tool-call attempts, or neither
|
|
87
|
+
// is. A one-sided check let malformed leading prose silently leak raw
|
|
88
|
+
// marker text into the visible chat response while malformed trailing prose
|
|
89
|
+
// hard-failed the turn.
|
|
90
|
+
//
|
|
91
|
+
// Requires the opening marker specifically: a response can only be
|
|
92
|
+
// *attempting* a tool call if it emits the open tag. A lone closing marker
|
|
93
|
+
// (e.g. prose that merely quotes or discusses `</pi_tool_call>`) is not a
|
|
94
|
+
// plausible tool-call attempt and must not hard-fail the turn.
|
|
95
|
+
export function isToolCallMarkerResponse(text: string): boolean {
|
|
96
|
+
const trimmed = text.trim();
|
|
97
|
+
return trimmed.includes(MARKER_OPEN);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
type MarkerClose = { index: number; length: number };
|
|
101
|
+
|
|
102
|
+
// Models occasionally vary whitespace, separators, plurality, or case in the
|
|
103
|
+
// closing tag. Keep the opening marker strict, but accept this bounded family
|
|
104
|
+
// of unmistakable Pi marker closers.
|
|
105
|
+
const MARKER_CLOSE_PATTERN = /^<\s*\/\s*pi[-_]tool[-_]calls?\s*>/i;
|
|
106
|
+
|
|
107
|
+
// A closing marker inside a JSON string (e.g. tool arguments that happen to
|
|
108
|
+
// contain the literal text "</pi_tool_call>") must not be treated as the
|
|
109
|
+
// real boundary, so this walks the JSON string-escaping state rather than
|
|
110
|
+
// matching the close tag with a plain regex.
|
|
111
|
+
function findMarkerClose(text: string, from: number): MarkerClose | undefined {
|
|
112
|
+
let inString = false;
|
|
113
|
+
let escaped = false;
|
|
114
|
+
for (let i = from; i < text.length; i++) {
|
|
115
|
+
const char = text[i];
|
|
116
|
+
if (inString) {
|
|
117
|
+
if (escaped) escaped = false;
|
|
118
|
+
else if (char === "\\") escaped = true;
|
|
119
|
+
else if (char === '"') inString = false;
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (char === '"') {
|
|
123
|
+
inString = true;
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
if (char !== "<") continue;
|
|
127
|
+
const match = text.slice(i).match(MARKER_CLOSE_PATTERN);
|
|
128
|
+
if (match) return { index: i, length: match[0].length };
|
|
129
|
+
}
|
|
130
|
+
return undefined;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Some models emit a complete marker payload but omit only the closing tag.
|
|
134
|
+
// Recover that narrow case by locating one complete top-level JSON object or
|
|
135
|
+
// array and requiring whitespace-only content afterward. Payload validation
|
|
136
|
+
// and the current-turn tool allowlist are still applied by the caller.
|
|
137
|
+
function recoverUnclosedMarkerBody(
|
|
138
|
+
text: string,
|
|
139
|
+
bodyStart: number,
|
|
140
|
+
): string | undefined {
|
|
141
|
+
let start = bodyStart;
|
|
142
|
+
while (/\s/.test(text[start] ?? "")) start++;
|
|
143
|
+
const first = text[start];
|
|
144
|
+
if (first !== "{" && first !== "[") return undefined;
|
|
145
|
+
|
|
146
|
+
const stack: string[] = [first];
|
|
147
|
+
let inString = false;
|
|
148
|
+
let escaped = false;
|
|
149
|
+
for (let i = start + 1; i < text.length; i++) {
|
|
150
|
+
const char = text[i];
|
|
151
|
+
if (inString) {
|
|
152
|
+
if (escaped) escaped = false;
|
|
153
|
+
else if (char === "\\") escaped = true;
|
|
154
|
+
else if (char === '"') inString = false;
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
if (char === '"') {
|
|
158
|
+
inString = true;
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
if (char === "{" || char === "[") {
|
|
162
|
+
stack.push(char);
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
if (char !== "}" && char !== "]") continue;
|
|
166
|
+
|
|
167
|
+
const expected = char === "}" ? "{" : "[";
|
|
168
|
+
if (stack.pop() !== expected) return undefined;
|
|
169
|
+
if (stack.length === 0) {
|
|
170
|
+
const end = i + 1;
|
|
171
|
+
return text.slice(end).trim() ? undefined : text.slice(bodyStart, end);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return undefined;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function normalizeQuotedToolCallResponse(text: string): string {
|
|
178
|
+
const trimmed = text.trim();
|
|
179
|
+
if (!trimmed.startsWith('"') || !trimmed.endsWith('"')) return text;
|
|
180
|
+
try {
|
|
181
|
+
const decoded: unknown = JSON.parse(trimmed);
|
|
182
|
+
return typeof decoded === "string" && decoded.includes(MARKER_OPEN)
|
|
183
|
+
? decoded
|
|
184
|
+
: text;
|
|
185
|
+
} catch {
|
|
186
|
+
return text;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Extract <pi_tool_call> marker bodies from the response text.
|
|
192
|
+
* Lenient: markers are extracted anywhere in the text, even with prose
|
|
193
|
+
* before or after them, matching how real models behave. A marker whose
|
|
194
|
+
* payload cannot be recovered (truncated JSON, unclosed with trailing
|
|
195
|
+
* content) is recorded as an invalid span instead of aborting extraction,
|
|
196
|
+
* so valid sibling markers still parse and the unrecoverable text can be
|
|
197
|
+
* stripped from the displayed response (#39).
|
|
198
|
+
*/
|
|
199
|
+
type MarkerSpan = { start: number; end: number };
|
|
200
|
+
|
|
201
|
+
type ExtractedMarker = { body: string; start: number; end: number };
|
|
202
|
+
|
|
203
|
+
type MarkerExtraction = {
|
|
204
|
+
markers: ExtractedMarker[];
|
|
205
|
+
invalidSpans: MarkerSpan[];
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
function toolCallMarkers(text: string): MarkerExtraction | undefined {
|
|
209
|
+
const trimmed = text.trim();
|
|
210
|
+
const markers: ExtractedMarker[] = [];
|
|
211
|
+
const invalidSpans: MarkerSpan[] = [];
|
|
212
|
+
let cursor = 0;
|
|
213
|
+
while (cursor < trimmed.length) {
|
|
214
|
+
const openIndex = trimmed.indexOf(MARKER_OPEN, cursor);
|
|
215
|
+
if (openIndex === -1) break;
|
|
216
|
+
|
|
217
|
+
const bodyStart = openIndex + MARKER_OPEN.length;
|
|
218
|
+
const close = findMarkerClose(trimmed, bodyStart);
|
|
219
|
+
if (close) {
|
|
220
|
+
markers.push({
|
|
221
|
+
body: trimmed.slice(bodyStart, close.index),
|
|
222
|
+
start: openIndex,
|
|
223
|
+
end: close.index + close.length,
|
|
224
|
+
});
|
|
225
|
+
cursor = close.index + close.length;
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const recovered = recoverUnclosedMarkerBody(trimmed, bodyStart);
|
|
230
|
+
if (recovered !== undefined) {
|
|
231
|
+
const end = bodyStart + recovered.length;
|
|
232
|
+
markers.push({ body: recovered, start: openIndex, end });
|
|
233
|
+
cursor = end;
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Unrecoverable marker: everything up to the next opening marker (or
|
|
238
|
+
// the end of the response) is inside the attempted marker, so strip it.
|
|
239
|
+
const nextOpen = trimmed.indexOf(MARKER_OPEN, bodyStart);
|
|
240
|
+
const spanEnd = nextOpen === -1 ? trimmed.length : nextOpen;
|
|
241
|
+
invalidSpans.push({ start: openIndex, end: spanEnd });
|
|
242
|
+
cursor = spanEnd;
|
|
243
|
+
}
|
|
244
|
+
if (markers.length === 0 && invalidSpans.length === 0) return undefined;
|
|
245
|
+
return { markers, invalidSpans };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function stripMarkerSpans(text: string, spans: MarkerSpan[]): string {
|
|
249
|
+
const sorted = [...spans].sort((a, b) => a.start - b.start);
|
|
250
|
+
let cleaned = "";
|
|
251
|
+
let cursor = 0;
|
|
252
|
+
for (const span of sorted) {
|
|
253
|
+
if (span.start < cursor) continue;
|
|
254
|
+
cleaned += text.slice(cursor, span.start);
|
|
255
|
+
cursor = Math.max(cursor, span.end);
|
|
256
|
+
}
|
|
257
|
+
cleaned += text.slice(cursor);
|
|
258
|
+
return cleaned;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// DeepSeek sometimes ignores the markers and writes its native DSML tool-call
|
|
262
|
+
// syntax as plain text. Each complete block is rewritten in place as markers,
|
|
263
|
+
// so call order and the tool-list check stay those of the marker parser.
|
|
264
|
+
const DSML = String.raw`[||]+\s*DSML\s*[||]+`;
|
|
265
|
+
const DSML_TAG = new RegExp(String.raw`<\/?${DSML}`);
|
|
266
|
+
const DSML_BLOCK = new RegExp(String.raw`^<${DSML}(?:tool|function)_calls>([\s\S]*?)<\/${DSML}(?:tool|function)_calls>`);
|
|
267
|
+
const DSML_INVOKE = new RegExp(String.raw`<${DSML}invoke\s+name="([^"]+)"\s*>([\s\S]*?)<\/${DSML}invoke>`, "g");
|
|
268
|
+
const DSML_PARAMETER = new RegExp(String.raw`<${DSML}parameter\s+name="([^"]+)"(?:\s+string="(true|false)")?\s*>([\s\S]*?)<\/${DSML}parameter>`, "g");
|
|
269
|
+
|
|
270
|
+
function dsmlBlockToMarkers(body: string): string {
|
|
271
|
+
let markers = "";
|
|
272
|
+
for (const [, name, params] of body.matchAll(DSML_INVOKE)) {
|
|
273
|
+
const args: Record<string, unknown> = {};
|
|
274
|
+
for (const [, key, isString, raw] of params.matchAll(DSML_PARAMETER)) {
|
|
275
|
+
if (isString === "true") {
|
|
276
|
+
args[key] = raw;
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
try {
|
|
280
|
+
args[key] = JSON.parse(raw);
|
|
281
|
+
} catch {
|
|
282
|
+
args[key] = raw;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
markers += `${MARKER_OPEN}${JSON.stringify({ name, arguments: args })}</pi_tool_call>`;
|
|
286
|
+
}
|
|
287
|
+
return markers;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Scans left to right and consumes whichever starts first, a marker or a DSML
|
|
291
|
+
// block, so each keeps the other's syntax intact inside its own arguments.
|
|
292
|
+
// Returns undefined for a truncated DSML block, which would otherwise end the
|
|
293
|
+
// turn silently or let Pi run only the calls before it.
|
|
294
|
+
function dsmlToMarkers(text: string): string | undefined {
|
|
295
|
+
let result = "";
|
|
296
|
+
let rest = text;
|
|
297
|
+
for (;;) {
|
|
298
|
+
const dsmlAt = rest.search(DSML_TAG);
|
|
299
|
+
const markerAt = rest.indexOf(MARKER_OPEN);
|
|
300
|
+
if (dsmlAt < 0) return result + rest;
|
|
301
|
+
if (markerAt >= 0 && markerAt < dsmlAt) {
|
|
302
|
+
// The slice starts at the marker, so trimming cannot shift its span.
|
|
303
|
+
const extraction = toolCallMarkers(rest.slice(markerAt));
|
|
304
|
+
const span = [...(extraction?.markers ?? []), ...(extraction?.invalidSpans ?? [])].find((s) => s.start === 0);
|
|
305
|
+
const end = markerAt + (span?.end ?? MARKER_OPEN.length);
|
|
306
|
+
result += rest.slice(0, end);
|
|
307
|
+
rest = rest.slice(end);
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
const block = DSML_BLOCK.exec(rest.slice(dsmlAt));
|
|
311
|
+
if (!block) return undefined;
|
|
312
|
+
result += rest.slice(0, dsmlAt) + dsmlBlockToMarkers(block[1]);
|
|
313
|
+
rest = rest.slice(dsmlAt + block[0].length);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export function parseToolCallResponse(
|
|
318
|
+
text: string,
|
|
319
|
+
allowedToolNames?: ReadonlySet<string>,
|
|
320
|
+
): ToolCallParseResult {
|
|
321
|
+
// A turn without tools (e.g. a compaction summary) cannot call any, so tool
|
|
322
|
+
// syntax it quotes is prose.
|
|
323
|
+
if (allowedToolNames?.size === 0) return { ok: true, calls: [] };
|
|
324
|
+
const normalizedText = dsmlToMarkers(normalizeQuotedToolCallResponse(text));
|
|
325
|
+
if (normalizedText === undefined) {
|
|
326
|
+
return { ok: false, rejection: { reason: "incomplete_dsml" } };
|
|
327
|
+
}
|
|
328
|
+
if (!isToolCallMarkerResponse(normalizedText)) return { ok: true, calls: [] };
|
|
329
|
+
|
|
330
|
+
const extraction = toolCallMarkers(normalizedText);
|
|
331
|
+
if (!extraction) return { ok: true, calls: [] };
|
|
332
|
+
|
|
333
|
+
const parsed: ParsedToolCall[] = [];
|
|
334
|
+
const strippedSpans: MarkerSpan[] = [...extraction.invalidSpans];
|
|
335
|
+
let payloadRejection:
|
|
336
|
+
| { reason: "invalid_payload" }
|
|
337
|
+
| undefined;
|
|
338
|
+
|
|
339
|
+
for (const marker of extraction.markers) {
|
|
340
|
+
const calls = parseToolCallJson(marker.body);
|
|
341
|
+
if (calls.length === 0) {
|
|
342
|
+
// Skip the invalid body but keep any valid sibling calls; if no
|
|
343
|
+
// call survives, surface the original corrective diagnostic.
|
|
344
|
+
payloadRejection ??= { reason: "invalid_payload" };
|
|
345
|
+
strippedSpans.push({ start: marker.start, end: marker.end });
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
const unavailable = calls.find(
|
|
349
|
+
(call) => allowedToolNames && !allowedToolNames.has(call.name),
|
|
350
|
+
);
|
|
351
|
+
if (unavailable) {
|
|
352
|
+
return {
|
|
353
|
+
ok: false,
|
|
354
|
+
rejection: {
|
|
355
|
+
reason: "unavailable_tool",
|
|
356
|
+
toolName: unavailable.name,
|
|
357
|
+
},
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
parsed.push(...calls);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
if (parsed.length === 0 && payloadRejection) {
|
|
364
|
+
return { ok: false, rejection: payloadRejection };
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if (strippedSpans.length === 0) return { ok: true, calls: parsed };
|
|
368
|
+
const cleanedText = stripMarkerSpans(normalizedText.trim(), strippedSpans);
|
|
369
|
+
if (parsed.length === 0 && !cleanedText.trim()) {
|
|
370
|
+
return { ok: false, rejection: { reason: "malformed_markers" } };
|
|
371
|
+
}
|
|
372
|
+
return { ok: true, calls: parsed, cleanedText };
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
export function parseToolCalls(
|
|
376
|
+
text: string,
|
|
377
|
+
allowedToolNames?: ReadonlySet<string>,
|
|
378
|
+
): ParsedToolCall[] {
|
|
379
|
+
const result = parseToolCallResponse(text, allowedToolNames);
|
|
380
|
+
return result.ok ? result.calls : [];
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Detects if the model response contains XML-style tool-use markup
|
|
385
|
+
* (e.g., Claude Code's <bash>, <read>, <arg_key>, <arg_value> tags).
|
|
386
|
+
* Returns the detected tool name if found, or undefined if not.
|
|
387
|
+
*/
|
|
388
|
+
export function detectXmlToolUse(text: string): string | undefined {
|
|
389
|
+
// Match XML-style tool tags that look like tool invocations.
|
|
390
|
+
// These are NOT <pi_tool_call> markers — they are the model's native format.
|
|
391
|
+
// Claude Code uses <bash>, <read>, <edit>, etc. directly, but may also
|
|
392
|
+
// emit <arg_key>/<arg_value> pairs inside those tags.
|
|
393
|
+
const xmlToolPattern = /<(bash|read|edit|write|glob|grep|ls|webfetch|websearch|task|todowrite|question|skill|lsp|external_directory|doom_loop|agent|arg_key|arg_value)\b/i;
|
|
394
|
+
const match = text.match(xmlToolPattern);
|
|
395
|
+
return match ? match[1].toLowerCase() : undefined;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
export function toolCallRejectionMessage(
|
|
399
|
+
result: Extract<ToolCallParseResult, { ok: false }>,
|
|
400
|
+
allowedToolNames: ReadonlySet<string>,
|
|
401
|
+
): string {
|
|
402
|
+
switch (result.rejection.reason) {
|
|
403
|
+
case "malformed_markers":
|
|
404
|
+
return "OpenCode returned malformed Pi tool-call markers. Ensure each marker contains valid JSON with a string \"name\" and object \"arguments\".";
|
|
405
|
+
case "invalid_payload":
|
|
406
|
+
return 'OpenCode returned an invalid Pi tool-call payload. Each marker must contain valid JSON with a non-empty string "name" and object "arguments".';
|
|
407
|
+
case "incomplete_dsml":
|
|
408
|
+
return "OpenCode returned an incomplete DeepSeek DSML tool call.";
|
|
409
|
+
case "unavailable_tool": {
|
|
410
|
+
const available = [...allowedToolNames].map((name) =>
|
|
411
|
+
JSON.stringify(name),
|
|
412
|
+
);
|
|
413
|
+
return `OpenCode requested unavailable Pi tool ${JSON.stringify(result.rejection.toolName)}. Use only tools available in the current Pi turn: ${available.length > 0 ? available.join(", ") : "none"}.`;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function parseArguments(value: unknown): Record<string, unknown> | undefined {
|
|
419
|
+
let parsed = value;
|
|
420
|
+
if (typeof parsed === "string") {
|
|
421
|
+
try {
|
|
422
|
+
parsed = JSON.parse(parsed);
|
|
423
|
+
} catch {
|
|
424
|
+
return undefined;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
428
|
+
return undefined;
|
|
429
|
+
}
|
|
430
|
+
return parsed as Record<string, unknown>;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function repairUnescapedJsonStringQuotes(raw: string): string | undefined {
|
|
434
|
+
const trimmed = raw.trim();
|
|
435
|
+
const matchingContainer =
|
|
436
|
+
(trimmed.startsWith("{") && trimmed.endsWith("}")) ||
|
|
437
|
+
(trimmed.startsWith("[") && trimmed.endsWith("]"));
|
|
438
|
+
if (!matchingContainer) return undefined;
|
|
439
|
+
|
|
440
|
+
let repaired = "";
|
|
441
|
+
let inString = false;
|
|
442
|
+
let escaped = false;
|
|
443
|
+
let changed = false;
|
|
444
|
+
|
|
445
|
+
for (let index = 0; index < trimmed.length; index++) {
|
|
446
|
+
const char = trimmed[index];
|
|
447
|
+
if (!inString) {
|
|
448
|
+
repaired += char;
|
|
449
|
+
if (char === '"') inString = true;
|
|
450
|
+
continue;
|
|
451
|
+
}
|
|
452
|
+
if (escaped) {
|
|
453
|
+
repaired += char;
|
|
454
|
+
escaped = false;
|
|
455
|
+
continue;
|
|
456
|
+
}
|
|
457
|
+
if (char === "\\") {
|
|
458
|
+
repaired += char;
|
|
459
|
+
escaped = true;
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
462
|
+
if (char !== '"') {
|
|
463
|
+
repaired += char;
|
|
464
|
+
continue;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
let nextIndex = index + 1;
|
|
468
|
+
while (/\s/.test(trimmed[nextIndex] ?? "")) nextIndex++;
|
|
469
|
+
const next = trimmed[nextIndex];
|
|
470
|
+
if (next === undefined || [",", ":", "}", "]"].includes(next)) {
|
|
471
|
+
repaired += char;
|
|
472
|
+
inString = false;
|
|
473
|
+
} else {
|
|
474
|
+
repaired += '\\"';
|
|
475
|
+
changed = true;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
return changed && !inString ? repaired : undefined;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function parseToolCallJson(raw: string): ParsedToolCall[] {
|
|
483
|
+
let value: unknown;
|
|
484
|
+
try {
|
|
485
|
+
value = JSON.parse(raw.trim());
|
|
486
|
+
} catch {
|
|
487
|
+
const repaired = repairUnescapedJsonStringQuotes(raw);
|
|
488
|
+
if (!repaired) return [];
|
|
489
|
+
try {
|
|
490
|
+
value = JSON.parse(repaired);
|
|
491
|
+
} catch {
|
|
492
|
+
return [];
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const container = value as { tool_calls?: unknown } | null;
|
|
497
|
+
const candidates = Array.isArray(value)
|
|
498
|
+
? value
|
|
499
|
+
: Array.isArray(container?.tool_calls)
|
|
500
|
+
? container.tool_calls
|
|
501
|
+
: [value];
|
|
502
|
+
if (candidates.length === 0) return [];
|
|
503
|
+
|
|
504
|
+
const calls: ParsedToolCall[] = [];
|
|
505
|
+
for (const rawCandidate of candidates) {
|
|
506
|
+
if (
|
|
507
|
+
typeof rawCandidate !== "object" ||
|
|
508
|
+
rawCandidate === null ||
|
|
509
|
+
Array.isArray(rawCandidate)
|
|
510
|
+
) {
|
|
511
|
+
return [];
|
|
512
|
+
}
|
|
513
|
+
const candidate = rawCandidate as {
|
|
514
|
+
name?: unknown;
|
|
515
|
+
tool?: unknown;
|
|
516
|
+
arguments?: unknown;
|
|
517
|
+
args?: unknown;
|
|
518
|
+
input?: unknown;
|
|
519
|
+
function?: { name?: unknown; arguments?: unknown };
|
|
520
|
+
};
|
|
521
|
+
const nameValue =
|
|
522
|
+
typeof candidate.name === "string"
|
|
523
|
+
? candidate.name
|
|
524
|
+
: typeof candidate.tool === "string"
|
|
525
|
+
? candidate.tool
|
|
526
|
+
: candidate.function?.name;
|
|
527
|
+
|
|
528
|
+
let argumentsValue: unknown;
|
|
529
|
+
if (Object.hasOwn(candidate, "arguments")) {
|
|
530
|
+
argumentsValue = candidate.arguments;
|
|
531
|
+
} else if (Object.hasOwn(candidate, "args")) {
|
|
532
|
+
argumentsValue = candidate.args;
|
|
533
|
+
} else if (Object.hasOwn(candidate, "input")) {
|
|
534
|
+
argumentsValue = candidate.input;
|
|
535
|
+
} else if (
|
|
536
|
+
candidate.function &&
|
|
537
|
+
typeof candidate.function === "object" &&
|
|
538
|
+
Object.hasOwn(candidate.function, "arguments")
|
|
539
|
+
) {
|
|
540
|
+
argumentsValue = candidate.function.arguments;
|
|
541
|
+
} else {
|
|
542
|
+
return [];
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
const args = parseArguments(argumentsValue);
|
|
546
|
+
if (
|
|
547
|
+
typeof nameValue !== "string" ||
|
|
548
|
+
!nameValue.trim() ||
|
|
549
|
+
!args
|
|
550
|
+
) {
|
|
551
|
+
return [];
|
|
552
|
+
}
|
|
553
|
+
calls.push({ name: nameValue, arguments: args });
|
|
554
|
+
}
|
|
555
|
+
return calls;
|
|
556
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { ThinkingLevelMap } from "@earendil-works/pi-ai";
|
|
2
|
+
|
|
3
|
+
export type ModelCost = {
|
|
4
|
+
input: number;
|
|
5
|
+
output: number;
|
|
6
|
+
cacheRead: number;
|
|
7
|
+
cacheWrite: number;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type OpenCodeModelInfo = {
|
|
11
|
+
id: string;
|
|
12
|
+
name: string;
|
|
13
|
+
reasoning: boolean;
|
|
14
|
+
image: boolean;
|
|
15
|
+
contextWindow: number;
|
|
16
|
+
maxTokens: number;
|
|
17
|
+
cost: ModelCost;
|
|
18
|
+
/** True when `cost` was read from discovery metadata rather than defaulted. */
|
|
19
|
+
costFromMetadata?: boolean;
|
|
20
|
+
status?: string;
|
|
21
|
+
thinkingLevelMap?: ThinkingLevelMap;
|
|
22
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export type ParsedToolCall = {
|
|
2
|
+
name: string;
|
|
3
|
+
arguments: Record<string, unknown>;
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
export type ToolCallParseResult =
|
|
7
|
+
| { ok: true; calls: ParsedToolCall[]; cleanedText?: string }
|
|
8
|
+
| {
|
|
9
|
+
ok: false;
|
|
10
|
+
rejection:
|
|
11
|
+
| { reason: "malformed_markers" }
|
|
12
|
+
| { reason: "invalid_payload" }
|
|
13
|
+
| { reason: "incomplete_dsml" }
|
|
14
|
+
| { reason: "unavailable_tool"; toolName: string };
|
|
15
|
+
};
|