pi-freeflow 1.36.0 → 1.36.1
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/CHANGELOG.md +27 -0
- package/package.json +1 -1
- package/src/opencode-fingerprint.ts +86 -4
- package/src/proxy.ts +32 -17
- package/src/stream-pipe.ts +85 -1
- package/src/tool-args.ts +194 -52
- package/src/tool-translation.ts +100 -84
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,32 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.36.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Fix tool calls being rejected as invalid JSON on every model, and tool names coming back under a name the host never registered.
|
|
8
|
+
|
|
9
|
+
- A tool whose arguments arrived carrying the tail of the previous call is now repaired for **every** tool, not only tools declaring a nested object or array. The most-used tool, the shell command tool, declares nothing but scalars, so it was never repaired at all — the call was rejected and the turn looped on the same broken payload.
|
|
10
|
+
- Buffered tool arguments are released when a stream ends without its normal ending event, instead of being dropped: a truncated stream used to deliver a call with no arguments at all.
|
|
11
|
+
- The tool-name restore now runs on the Cline and OVH lanes too. Those lanes send a renamed tool upstream but never took it back, so a client received a call for a tool it does not have.
|
|
12
|
+
- A caller that declares both file-pattern and semantic-search tools now keeps both. The two share a name across hosts but are different tools, and collapsing them handed the model one tool under the other's shape.
|
|
13
|
+
- A truncated number is refused rather than completed, and an inner object is never promoted over the wrapper that declares it.
|
|
14
|
+
- A non-streaming answer from the Kilo lane is repaired like a streamed one.
|
|
15
|
+
- The free tier is the only lane that renames a tool now. The other lanes carry
|
|
16
|
+
the caller's own names in both directions, so the host dispatches natively
|
|
17
|
+
instead of waiting for a name that never comes back.
|
|
18
|
+
- Tools that declare an optional value as "value or nothing" are repaired
|
|
19
|
+
again. Both hosts describe optional settings that way when a tool is declared
|
|
20
|
+
strictly, and reading that as "always send it" silently switched repair off
|
|
21
|
+
for the shell command tool — the one that matters most.
|
|
22
|
+
- A model answering an optional setting with the word "none" as text, instead of
|
|
23
|
+
leaving it empty, is now corrected rather than rejected. Affects roughly one
|
|
24
|
+
model in nine.
|
|
25
|
+
- A streamed Anthropic-format answer is now repaired and mapped like the other
|
|
26
|
+
two formats. It was forwarded untouched.
|
|
27
|
+
- A stream that ends without an answer says so, instead of reporting an empty
|
|
28
|
+
successful one.
|
|
29
|
+
|
|
3
30
|
## 1.36.0
|
|
4
31
|
|
|
5
32
|
### Minor Changes
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-freeflow",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "1.36.
|
|
4
|
+
"version": "1.36.1",
|
|
5
5
|
"description": "Thin provider for OMP/Pi — model list + dumb relay proxy + log; host pi-ai owns thinking/normalization",
|
|
6
6
|
"main": "extensions/index.ts",
|
|
7
7
|
"types": "src/index.ts",
|
|
@@ -557,8 +557,11 @@ export function enforceOpencodeFingerprint(
|
|
|
557
557
|
const injected = missing.filter((n) => !ompLike || n === "edit");
|
|
558
558
|
const injectedReal = ompLike ? missing.filter((n) => n !== "edit") : [];
|
|
559
559
|
|
|
560
|
+
// Zen alone asks for a rename: its free-tier gate looks for the sextet name
|
|
561
|
+
// `glob`, so a Pi caller that sent `find` needs it renamed into that slot.
|
|
562
|
+
// Every other lane accepts the caller's own names untouched.
|
|
560
563
|
if (Array.isArray(body.tools)) {
|
|
561
|
-
body.tools = translateToolsForPath(body.tools, pathname);
|
|
564
|
+
body.tools = translateToolsForPath(body.tools, pathname, { forFingerprint: true });
|
|
562
565
|
}
|
|
563
566
|
|
|
564
567
|
const before = Array.isArray(body.tools) ? body.tools.length : 0;
|
|
@@ -881,10 +884,14 @@ export function sseToResponsesJson(
|
|
|
881
884
|
}
|
|
882
885
|
} catch { }
|
|
883
886
|
|
|
887
|
+
// Nothing in the stream carried a response object: the upstream turn ended
|
|
888
|
+
// without one. Report that honestly — a `completed` answer with zero output
|
|
889
|
+
// reads to the host as "the model chose to say nothing", which is a
|
|
890
|
+
// different (and wrong) claim than "the stream was cut".
|
|
884
891
|
return {
|
|
885
892
|
id: "resp_fallback",
|
|
886
893
|
object: "response",
|
|
887
|
-
status: "
|
|
894
|
+
status: "incomplete",
|
|
888
895
|
output: [],
|
|
889
896
|
};
|
|
890
897
|
}
|
|
@@ -895,8 +902,11 @@ export function sseToResponsesJson(
|
|
|
895
902
|
* never see tool_use: stray placeholder input folds to text, matching the
|
|
896
903
|
* SSE aggregator fallback below. Ids ride verbatim; `input` is repaired
|
|
897
904
|
* against the caller's declared schema when the emitted shape cannot be right.
|
|
905
|
+
*
|
|
906
|
+
* Exported so the streaming cloak applies these identical rules to a `message`
|
|
907
|
+
* frame that arrives mid-stream, instead of forwarding it verbatim.
|
|
898
908
|
*/
|
|
899
|
-
function cloakMessagesContent(
|
|
909
|
+
export function cloakMessagesContent(
|
|
900
910
|
msg: unknown,
|
|
901
911
|
callerHadTools: boolean,
|
|
902
912
|
caseRestore?: CaseRestoreMap,
|
|
@@ -1089,7 +1099,79 @@ export function convertSseToJson(
|
|
|
1089
1099
|
return JSON.stringify(sseToMessagesJson(trimmed, callerHadTools, caseRestore, findGlob, injected, argSchemas));
|
|
1090
1100
|
}
|
|
1091
1101
|
return JSON.stringify(sseToChatCompletionJson(trimmed, callerHadTools, caseRestore, findGlob, injected, argSchemas));
|
|
1102
|
+
} catch (error) {
|
|
1103
|
+
// Every caller has already committed to `content-type: application/json`
|
|
1104
|
+
// by the time this runs, so handing back the raw SSE bytes would deliver a
|
|
1105
|
+
// parse error with no diagnosis. Say what happened, and keep the payload
|
|
1106
|
+
// reachable under `raw` so nothing is silently destroyed.
|
|
1107
|
+
return JSON.stringify({
|
|
1108
|
+
error: {
|
|
1109
|
+
message: "upstream stream could not be aggregated into a JSON answer",
|
|
1110
|
+
detail: String(error).slice(0, 300),
|
|
1111
|
+
raw: sseText.slice(0, 2000),
|
|
1112
|
+
},
|
|
1113
|
+
});
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
/**
|
|
1118
|
+
* Repair the tool calls inside an already-JSON answer body.
|
|
1119
|
+
*
|
|
1120
|
+
* A lane that passes the caller's `stream` through untouched (Kilo) answers a
|
|
1121
|
+
* non-streaming caller with a plain `chat.completion` / `response` object, not
|
|
1122
|
+
* SSE. The SSE aggregators above never see that body, so the malformed
|
|
1123
|
+
* arguments reached the host unrepaired on exactly the requests that asked for
|
|
1124
|
+
* no stream. Repairs each call in place and re-serializes only when something
|
|
1125
|
+
* actually changed, so a well-formed answer keeps its byte-identical body.
|
|
1126
|
+
* Returns the input string untouched when it is not JSON or has no tool calls.
|
|
1127
|
+
*/
|
|
1128
|
+
export function repairJsonToolArguments(
|
|
1129
|
+
bodyText: string,
|
|
1130
|
+
pathname: string,
|
|
1131
|
+
argSchemas: ReadonlyMap<string, Record<string, unknown>> | undefined,
|
|
1132
|
+
): string {
|
|
1133
|
+
if (argSchemas === undefined || argSchemas.size === 0 || !bodyText.trimStart().startsWith("{")) return bodyText;
|
|
1134
|
+
let parsed: unknown;
|
|
1135
|
+
try {
|
|
1136
|
+
parsed = JSON.parse(bodyText);
|
|
1092
1137
|
} catch {
|
|
1093
|
-
return
|
|
1138
|
+
return bodyText;
|
|
1139
|
+
}
|
|
1140
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return bodyText;
|
|
1141
|
+
const body = parsed as Record<string, unknown>;
|
|
1142
|
+
let changed = false;
|
|
1143
|
+
const repairCall = (name: string, args: string): string => {
|
|
1144
|
+
const repaired = repairToolArguments(name, args, argSchemas);
|
|
1145
|
+
if (repaired !== args) changed = true;
|
|
1146
|
+
return repaired;
|
|
1147
|
+
};
|
|
1148
|
+
if (pathname.endsWith("/responses")) {
|
|
1149
|
+
const output = body.output;
|
|
1150
|
+
if (!Array.isArray(output)) return bodyText;
|
|
1151
|
+
for (const item of output) {
|
|
1152
|
+
if (item === null || typeof item !== "object" || Array.isArray(item)) continue;
|
|
1153
|
+
const rec = item as Record<string, unknown>;
|
|
1154
|
+
if (rec.type !== "function_call" || typeof rec.name !== "string" || typeof rec.arguments !== "string") continue;
|
|
1155
|
+
rec.arguments = repairCall(rec.name, rec.arguments);
|
|
1156
|
+
}
|
|
1157
|
+
} else {
|
|
1158
|
+
const choices = body.choices;
|
|
1159
|
+
if (!Array.isArray(choices)) return bodyText;
|
|
1160
|
+
for (const choice of choices) {
|
|
1161
|
+
if (choice === null || typeof choice !== "object" || Array.isArray(choice)) continue;
|
|
1162
|
+
const message = (choice as Record<string, unknown>).message;
|
|
1163
|
+
if (message === null || typeof message !== "object" || Array.isArray(message)) continue;
|
|
1164
|
+
const calls = (message as Record<string, unknown>).tool_calls;
|
|
1165
|
+
if (!Array.isArray(calls)) continue;
|
|
1166
|
+
for (const call of calls) {
|
|
1167
|
+
if (call === null || typeof call !== "object" || Array.isArray(call)) continue;
|
|
1168
|
+
const fn = (call as Record<string, unknown>).function;
|
|
1169
|
+
if (fn === null || typeof fn !== "object" || Array.isArray(fn)) continue;
|
|
1170
|
+
const fnRec = fn as Record<string, unknown>;
|
|
1171
|
+
if (typeof fnRec.name !== "string" || typeof fnRec.arguments !== "string") continue;
|
|
1172
|
+
fnRec.arguments = repairCall(fnRec.name, fnRec.arguments);
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1094
1175
|
}
|
|
1176
|
+
return changed ? JSON.stringify(body) : bodyText;
|
|
1095
1177
|
}
|
package/src/proxy.ts
CHANGED
|
@@ -35,11 +35,12 @@ import {
|
|
|
35
35
|
} from "./config.ts";
|
|
36
36
|
|
|
37
37
|
import {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
38
|
+
convertSseToJson,
|
|
39
|
+
enforceOpencodeFingerprint,
|
|
40
|
+
repairJsonToolArguments,
|
|
41
|
+
sseToChatCompletionJson,
|
|
42
|
+
type CaseRestoreMap,
|
|
43
|
+
type FindGlobRestore,
|
|
43
44
|
} from "./opencode-fingerprint.ts";
|
|
44
45
|
import { isDebugEnabled, log } from "./logger.ts";
|
|
45
46
|
import { getModelUpstream, isClineModel, isOvhModel, KILO_MODEL_IDS, MODEL_MAP, ovhUpstreamModelId, resolveCanonicalModelId } from "./models.ts";
|
|
@@ -47,10 +48,12 @@ import { ClineAuthError, refreshClineToken, toApiKey } from "./cline-device-auth
|
|
|
47
48
|
import { rollChat } from "./cline-accounts.ts";
|
|
48
49
|
import { fetchWithSystemCA } from "./system-ca-fetch.ts";
|
|
49
50
|
import {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
51
|
+
buildFindGlobRestore,
|
|
52
|
+
chatBodyFromResponsesBody,
|
|
53
|
+
chatResponsesJsonFromChatCompletion,
|
|
54
|
+
chatResponsesSseFromChatCompletion,
|
|
55
|
+
restoreToolNameForCaller,
|
|
56
|
+
translateToolsForPath,
|
|
54
57
|
} from "./tool-translation.ts";
|
|
55
58
|
import { buildArgRepairSchemas } from "./tool-args.ts";
|
|
56
59
|
// normalize removed — host pi-ai already normalizes thinking/reasoning before proxy
|
|
@@ -271,6 +274,10 @@ async function handleClineRequest(opts: {
|
|
|
271
274
|
// Cline bypasses the fingerprint, but its arguments still malform the same
|
|
272
275
|
// way: capture the caller's own schemas before translation for repair.
|
|
273
276
|
const argSchemas = buildArgRepairSchemas(Array.isArray(parsedBody.tools) ? parsedBody.tools : []);
|
|
277
|
+
// Translation renames the caller's `find` to `glob` upstream and folds any
|
|
278
|
+
// name the upstream shape forbids; without this map the answer returns under
|
|
279
|
+
// a name Pi never registered.
|
|
280
|
+
const findGlob = buildFindGlobRestore(Array.isArray(parsedBody.tools) ? parsedBody.tools : []);
|
|
274
281
|
chatBody.stream = true;
|
|
275
282
|
let upstreamRes: Response;
|
|
276
283
|
let limitHint: ClineLimitHint | undefined;
|
|
@@ -334,8 +341,8 @@ async function handleClineRequest(opts: {
|
|
|
334
341
|
req,
|
|
335
342
|
reqId,
|
|
336
343
|
"direct",
|
|
337
|
-
argSchemas.size > 0
|
|
338
|
-
? { callerHadTools: true, argSchemas, pathname: "/v1/chat/completions" }
|
|
344
|
+
findGlob.renamedFindToGlob || findGlob.renamed !== undefined || argSchemas.size > 0
|
|
345
|
+
? { callerHadTools: true, argSchemas, findGlob, pathname: "/v1/chat/completions" }
|
|
339
346
|
: undefined,
|
|
340
347
|
);
|
|
341
348
|
return;
|
|
@@ -352,7 +359,7 @@ async function handleClineRequest(opts: {
|
|
|
352
359
|
return;
|
|
353
360
|
}
|
|
354
361
|
if (!responsesRequest) {
|
|
355
|
-
|
|
362
|
+
const data = convertSseToJson(sseText, pathname, true, undefined, findGlob, undefined, argSchemas);
|
|
356
363
|
if (!res.headersSent) {
|
|
357
364
|
res.writeHead(upstreamRes.status, { "content-type": "application/json" });
|
|
358
365
|
res.end(data);
|
|
@@ -361,7 +368,7 @@ async function handleClineRequest(opts: {
|
|
|
361
368
|
}
|
|
362
369
|
return;
|
|
363
370
|
}
|
|
364
|
-
const chat = sseToChatCompletionJson(sseText, true, undefined,
|
|
371
|
+
const chat = sseToChatCompletionJson(sseText, true, undefined, findGlob, undefined, argSchemas);
|
|
365
372
|
const resp = chatResponsesJsonFromChatCompletion(chat, model);
|
|
366
373
|
if (clientRequestedStream) {
|
|
367
374
|
if (!res.headersSent) {
|
|
@@ -423,6 +430,10 @@ async function handleOvhRequest(opts: {
|
|
|
423
430
|
// Bypassing the fingerprint does not exempt OVH from malformed tool
|
|
424
431
|
// arguments: capture the caller's own schemas before translation for repair.
|
|
425
432
|
const argSchemas = buildArgRepairSchemas(Array.isArray(parsedBody.tools) ? parsedBody.tools : []);
|
|
433
|
+
// Translation renames the caller's `find` to `glob` upstream and folds any
|
|
434
|
+
// name the upstream shape forbids; without this map the answer returns under
|
|
435
|
+
// a name Pi never registered.
|
|
436
|
+
const findGlob = buildFindGlobRestore(Array.isArray(parsedBody.tools) ? parsedBody.tools : []);
|
|
426
437
|
chatBody.stream = true;
|
|
427
438
|
// Header-wait timeout + client-disconnect abort, cleared once headers arrive
|
|
428
439
|
// so a long stream is not killed at the timeout ceiling.
|
|
@@ -501,8 +512,8 @@ async function handleOvhRequest(opts: {
|
|
|
501
512
|
req,
|
|
502
513
|
reqId,
|
|
503
514
|
"direct",
|
|
504
|
-
argSchemas.size > 0
|
|
505
|
-
? { callerHadTools: true, argSchemas, pathname: "/v1/chat/completions" }
|
|
515
|
+
findGlob.renamedFindToGlob || findGlob.renamed !== undefined || argSchemas.size > 0
|
|
516
|
+
? { callerHadTools: true, argSchemas, findGlob, pathname: "/v1/chat/completions" }
|
|
506
517
|
: undefined,
|
|
507
518
|
);
|
|
508
519
|
return;
|
|
@@ -519,7 +530,7 @@ async function handleOvhRequest(opts: {
|
|
|
519
530
|
return;
|
|
520
531
|
}
|
|
521
532
|
if (!responsesRequest) {
|
|
522
|
-
|
|
533
|
+
const data = convertSseToJson(sseText, pathname, true, undefined, findGlob, undefined, argSchemas);
|
|
523
534
|
if (!res.headersSent) {
|
|
524
535
|
res.writeHead(upstreamRes.status, { "content-type": "application/json" });
|
|
525
536
|
res.end(data);
|
|
@@ -528,7 +539,7 @@ async function handleOvhRequest(opts: {
|
|
|
528
539
|
}
|
|
529
540
|
return;
|
|
530
541
|
}
|
|
531
|
-
const chat = sseToChatCompletionJson(sseText, true, undefined,
|
|
542
|
+
const chat = sseToChatCompletionJson(sseText, true, undefined, findGlob, undefined, argSchemas);
|
|
532
543
|
const resp = chatResponsesJsonFromChatCompletion(chat, requestedModel);
|
|
533
544
|
if (clientRequestedStream) {
|
|
534
545
|
if (!res.headersSent) {
|
|
@@ -1382,7 +1393,11 @@ export function startProxy(
|
|
|
1382
1393
|
} else {
|
|
1383
1394
|
let rawText = await response.text();
|
|
1384
1395
|
if (response.ok && kiloArgSchemas.size > 0) {
|
|
1396
|
+
// Kilo passes `stream` through verbatim, so a non-streaming caller gets
|
|
1397
|
+
// a plain JSON body: the SSE aggregator is a no-op on it, and the JSON
|
|
1398
|
+
// repair is what actually applies here.
|
|
1385
1399
|
rawText = convertSseToJson(rawText, target.pathname, true, undefined, undefined, undefined, kiloArgSchemas);
|
|
1400
|
+
rawText = repairJsonToolArguments(rawText, target.pathname, kiloArgSchemas);
|
|
1386
1401
|
}
|
|
1387
1402
|
const data = withRateLimitHint(response.status, rawText);
|
|
1388
1403
|
const ct = response.headers.get("content-type") || "application/json";
|
package/src/stream-pipe.ts
CHANGED
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
WATCHDOG_SSE_MIN_SAMPLES,
|
|
16
16
|
WATCHDOG_SSE_WINDOW,
|
|
17
17
|
} from "./config.ts";
|
|
18
|
-
import type
|
|
18
|
+
import { cloakMessagesContent, type CaseRestoreMap, type FindGlobRestore } from "./opencode-fingerprint.ts";
|
|
19
19
|
import { restoreToolNameForCaller } from "./tool-translation.ts";
|
|
20
20
|
import { repairToolArguments } from "./tool-args.ts";
|
|
21
21
|
|
|
@@ -211,6 +211,52 @@ export function createSseStreamCloakState(): SseStreamCloakState {
|
|
|
211
211
|
};
|
|
212
212
|
}
|
|
213
213
|
|
|
214
|
+
/**
|
|
215
|
+
* Emit every tool call still holding buffered arguments, then clear the
|
|
216
|
+
* buffers. The fragments were withheld so the packet could be judged whole
|
|
217
|
+
* (see {@link SseStreamCloakState}); the terminal event that normally releases
|
|
218
|
+
* them never arrived when upstream cut the stream without one, and without
|
|
219
|
+
* this the client would receive a tool call whose `arguments` were deleted and
|
|
220
|
+
* never re-emitted. Repaired exactly as the terminal path repairs.
|
|
221
|
+
*
|
|
222
|
+
* Shape follows the pathname: Chat re-emits one `tool_calls` delta, Anthropic
|
|
223
|
+
* one `input_json_delta` per open block. Responses carries whole arguments on
|
|
224
|
+
* its `.done` event and buffers nothing, so it has nothing to flush.
|
|
225
|
+
* Returns null when nothing is buffered.
|
|
226
|
+
*/
|
|
227
|
+
export function flushBufferedToolArgs(
|
|
228
|
+
state: SseStreamCloakState,
|
|
229
|
+
cloak: StreamCloakOptions,
|
|
230
|
+
): string | null {
|
|
231
|
+
if (state.messagesArgsByIndex.size > 0) {
|
|
232
|
+
const blocks: string[] = [];
|
|
233
|
+
for (const [index, args] of state.messagesArgsByIndex) {
|
|
234
|
+
const name = state.messagesNameByIndex.get(index) ?? "";
|
|
235
|
+
blocks.push(
|
|
236
|
+
`event: content_block_delta\ndata: ${JSON.stringify({
|
|
237
|
+
type: "content_block_delta",
|
|
238
|
+
index,
|
|
239
|
+
delta: { type: "input_json_delta", partial_json: repairToolArguments(name, args, cloak.argSchemas) },
|
|
240
|
+
})}\n\n`,
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
state.messagesArgsByIndex.clear();
|
|
244
|
+
state.messagesNameByIndex.clear();
|
|
245
|
+
return blocks.join("");
|
|
246
|
+
}
|
|
247
|
+
if (state.chatArgsByIndex.size === 0) return null;
|
|
248
|
+
const calls: unknown[] = [];
|
|
249
|
+
for (const [index, args] of state.chatArgsByIndex) {
|
|
250
|
+
const name = state.chatNameByIndex.get(index);
|
|
251
|
+
if (name === undefined) continue;
|
|
252
|
+
calls.push({ index, type: "function", function: { name, arguments: repairToolArguments(name, args, cloak.argSchemas) } });
|
|
253
|
+
}
|
|
254
|
+
state.chatArgsByIndex.clear();
|
|
255
|
+
state.chatNameByIndex.clear();
|
|
256
|
+
if (calls.length === 0) return null;
|
|
257
|
+
return `data: ${JSON.stringify({ object: "chat.completion.chunk", choices: [{ index: 0, delta: { tool_calls: calls } }] })}\n\n`;
|
|
258
|
+
}
|
|
259
|
+
|
|
214
260
|
/**
|
|
215
261
|
* True when the stream needs no rewrite and must flow byte-identical:
|
|
216
262
|
* nothing injected, no casing to restore, no find->glob rename, and the
|
|
@@ -656,6 +702,27 @@ function rewriteMessagesBlock(
|
|
|
656
702
|
const stopBlock = emitSseBlock(event, JSON.stringify(parsed), hadEventPrefix);
|
|
657
703
|
return `${emitSseBlock("content_block_delta", JSON.stringify(flushed), false)}\n\n${stopBlock}`;
|
|
658
704
|
}
|
|
705
|
+
// A `message` frame carries a whole finished message mid-stream. It needs the
|
|
706
|
+
// same treatment the aggregate path gives it — injected tool_use dropped,
|
|
707
|
+
// caller names restored, inputs repaired — otherwise a placeholder call leaks
|
|
708
|
+
// downstream and a malformed one reaches the host unrepaired. Any block still
|
|
709
|
+
// buffering arguments is completed first so nothing is orphaned.
|
|
710
|
+
if (type === "message") {
|
|
711
|
+
const flushed = flushBufferedToolArgs(state, cloak);
|
|
712
|
+
cloakMessagesContent(parsed.message ?? parsed, cloak.callerHadTools, cloak.caseRestore, cloak.findGlob, cloak.injected, cloak.argSchemas);
|
|
713
|
+
if (flushed === null) return emitSseBlock(event, JSON.stringify(parsed), hadEventPrefix);
|
|
714
|
+
return `${flushed}${emitSseBlock(event, JSON.stringify(parsed), hadEventPrefix)}`;
|
|
715
|
+
}
|
|
716
|
+
// A tool_use block still holding buffered arguments is completed HERE, while
|
|
717
|
+
// its own event order is intact: `message_delta` / `message_stop` close the
|
|
718
|
+
// message, so the recovered `input_json_delta` has to precede them. (An
|
|
719
|
+
// end-of-stream flush would emit it after `message_stop`, where a strict
|
|
720
|
+
// Anthropic client has already closed the message.)
|
|
721
|
+
if (type === "message_delta" || type === "message_stop") {
|
|
722
|
+
const flushed = flushBufferedToolArgs(state, cloak);
|
|
723
|
+
if (flushed === null) return block;
|
|
724
|
+
return `${flushed}${emitSseBlock(event, JSON.stringify(parsed), hadEventPrefix)}`;
|
|
725
|
+
}
|
|
659
726
|
return block;
|
|
660
727
|
}
|
|
661
728
|
|
|
@@ -1099,6 +1166,15 @@ export function pipeUpstreamStream(
|
|
|
1099
1166
|
}
|
|
1100
1167
|
}
|
|
1101
1168
|
}
|
|
1169
|
+
// Buffered tool arguments are released on the call's terminal event, which a
|
|
1170
|
+
// cut stream never sends. Flush them here: the client already saw the call
|
|
1171
|
+
// announced, so it must receive the arguments too rather than a name with no
|
|
1172
|
+
// payload. Runs before the terminal-marker decision and before the re-fire
|
|
1173
|
+
// offer, because a rescue regenerates the turn and would double the calls.
|
|
1174
|
+
if (cloakState && cloak) {
|
|
1175
|
+
const flushed = flushBufferedToolArgs(cloakState, cloak);
|
|
1176
|
+
if (flushed !== null) forwardText(flushed);
|
|
1177
|
+
}
|
|
1102
1178
|
if (hasTerminalEvent) recordOnce(false);
|
|
1103
1179
|
const elapsed = ((Date.now() - startAt) / 1000).toFixed(1);
|
|
1104
1180
|
if (!hasTerminalEvent && totalChunks > 0) {
|
|
@@ -1140,6 +1216,14 @@ export function pipeUpstreamStream(
|
|
|
1140
1216
|
// inject a terminal nor end res — the replacement stream owns the turn now.
|
|
1141
1217
|
if (refirePending) return;
|
|
1142
1218
|
try {
|
|
1219
|
+
// Same obligation as the clean-end flush: the client already saw the call
|
|
1220
|
+
// announced, so its buffered arguments must still arrive before the turn is
|
|
1221
|
+
// terminated — and before the re-fire offer, since a rescue regenerates the
|
|
1222
|
+
// turn and would double the calls.
|
|
1223
|
+
if (cloakState && cloak) {
|
|
1224
|
+
const flushed = flushBufferedToolArgs(cloakState, cloak);
|
|
1225
|
+
if (flushed !== null) forwardText(flushed);
|
|
1226
|
+
}
|
|
1143
1227
|
if (!hasTerminalEvent && totalChunks > 0) {
|
|
1144
1228
|
if (clientAborted || upstreamEnded) {
|
|
1145
1229
|
// Client disconnect teardown or post-end cleanup — the relay
|
package/src/tool-args.ts
CHANGED
|
@@ -9,9 +9,12 @@
|
|
|
9
9
|
* `items: { item: [...] }`. Sibling free models invented whole vocabularies
|
|
10
10
|
* (`phases` / `name` / `tasks`) the schema never declared.
|
|
11
11
|
*
|
|
12
|
-
*
|
|
12
|
+
* Three families are mechanically decidable from the CALLER's own declared
|
|
13
13
|
* schema, and this module repairs exactly those:
|
|
14
14
|
*
|
|
15
|
+
* - Prefix-glue salvage: an argument string carrying the tail of the previous
|
|
16
|
+
* call in front of the caller's own object is recovered (see parseArguments).
|
|
17
|
+
*
|
|
15
18
|
* - Envelope unwrap: a declared array that arrives wrapped in a single-key
|
|
16
19
|
* object (`items: { item: [...] }`) is unwrapped.
|
|
17
20
|
* - JSON-string decode: a declared object/array that arrives as a JSON string
|
|
@@ -38,6 +41,14 @@ import { canonicalizeTool } from "./tool-translation.ts";
|
|
|
38
41
|
/** Bound on recursive repair/validation; real host schemas nest 3-4 deep. */
|
|
39
42
|
const MAX_DEPTH = 8;
|
|
40
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Text ending in a bare numeric literal (`"timeout":18`, `"n":1.`, `"n":1e`).
|
|
46
|
+
* A complete JSON value is never followed by a digit, so this can only be a
|
|
47
|
+
* literal the model was still writing when the stream cut — see
|
|
48
|
+
* {@link appendClosers}.
|
|
49
|
+
*/
|
|
50
|
+
const CUT_NUMBER_TAIL = /[\d.eE+-]$/;
|
|
51
|
+
|
|
41
52
|
/** Declared JSON Schema type, tolerating union types like ["array","null"]. */
|
|
42
53
|
function schemaType(schema: Record<string, unknown>): string {
|
|
43
54
|
const declared = schema.type;
|
|
@@ -49,10 +60,47 @@ function schemaType(schema: Record<string, unknown>): string {
|
|
|
49
60
|
return "";
|
|
50
61
|
}
|
|
51
62
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Does a value already satisfy the declared schema? Type, then required keys,
|
|
66
|
+
* then each present property recursively. A present property the schema does
|
|
67
|
+
* not declare is ignored (never fails): hosts decide their own extras policy,
|
|
68
|
+
* and this only gates whether a promotion is safe.
|
|
69
|
+
*
|
|
70
|
+
* Deliberately reads five keywords — `type`, `enum`, `properties`, `items`,
|
|
71
|
+
* `required` — and nothing else. `minItems`, `minLength`, `pattern`,
|
|
72
|
+
* `additionalProperties` and `strict` are NOT enforced, on purpose: this
|
|
73
|
+
* function only decides whether to hand the repaired value to the host, and
|
|
74
|
+
* the host validates the same keywords itself. A payload that violates
|
|
75
|
+
* `minItems` is rejected by the host whether or not the repair accepted it, so
|
|
76
|
+
* enforcing it here could only ever DISABLE a repair that would have been
|
|
77
|
+
* accepted downstream — strictly worse, never better. The trade-off this does
|
|
78
|
+
* leave: repair never rescues a call whose only fault is a constraint like
|
|
79
|
+
* `minItems`, because the shape was never the problem.
|
|
80
|
+
*/
|
|
81
|
+
/**
|
|
82
|
+
* Is this property allowed to be absent?
|
|
83
|
+
*
|
|
84
|
+
* Both hosts rewrite a tool's schema for strict mode by listing EVERY property
|
|
85
|
+
* key in `required` and wrapping each optional one in
|
|
86
|
+
* `anyOf: [<schema>, { type: "null" }]` (OMP: packages/ai CONSTRAINTS.md:34-49;
|
|
87
|
+
* Pi: core/constrained-sampling.ts:118-123). So "required" there does not mean
|
|
88
|
+
* "must be present" — it means "must be addressable". Reading it literally made
|
|
89
|
+
* this gate refuse every well-formed call on a strict tool, which silently
|
|
90
|
+
* disabled the whole repair for `bash` and the strict edit modes: the glued
|
|
91
|
+
* payload the host rejects is exactly the one this module exists to fix.
|
|
92
|
+
*/
|
|
93
|
+
function isNullableProperty(prop: unknown): boolean {
|
|
94
|
+
if (prop === null || typeof prop !== "object" || Array.isArray(prop)) return false;
|
|
95
|
+
const rec = prop as Record<string, unknown>;
|
|
96
|
+
if (Array.isArray(rec.type)) return (rec.type as unknown[]).includes("null");
|
|
97
|
+
const anyOf = rec.anyOf;
|
|
98
|
+
if (Array.isArray(anyOf)) {
|
|
99
|
+
for (const branch of anyOf) {
|
|
100
|
+
if (branch !== null && typeof branch === "object" && !Array.isArray(branch) && (branch as Record<string, unknown>).type === "null") return true;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return false;
|
|
56
104
|
}
|
|
57
105
|
|
|
58
106
|
/**
|
|
@@ -60,38 +108,74 @@ function structuralProperty(schema: Record<string, unknown>): boolean {
|
|
|
60
108
|
* then each present property recursively. A present property the schema does
|
|
61
109
|
* not declare is ignored (never fails): hosts decide their own extras policy,
|
|
62
110
|
* and this only gates whether a promotion is safe.
|
|
111
|
+
*
|
|
112
|
+
* Deliberately reads `type`, `enum`, `properties`, `items`, `required` — and
|
|
113
|
+
* nothing else. `minItems`, `minLength`, `pattern`, `additionalProperties` and
|
|
114
|
+
* `strict` are NOT enforced, on purpose: this function only decides whether to
|
|
115
|
+
* hand the repaired value to the host, and the host validates the same
|
|
116
|
+
* keywords itself. A payload that violates `minItems` is rejected by the host
|
|
117
|
+
* whether or not the repair accepted it, so enforcing it here could only ever
|
|
118
|
+
* DISABLE a repair the host would otherwise have accepted — strictly worse,
|
|
119
|
+
* never better. The trade-off this leaves: repair never rescues a call whose
|
|
120
|
+
* only fault is a constraint like `minItems`, because the shape was never the
|
|
121
|
+
* problem.
|
|
63
122
|
*/
|
|
64
123
|
function matchesSchema(value: unknown, schema: Record<string, unknown>, depth: number): boolean {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
if (propSchema === null || typeof propSchema !== "object" || Array.isArray(propSchema)) continue;
|
|
86
|
-
if (!matchesSchema(entry, propSchema as Record<string, unknown>, depth + 1)) return false;
|
|
124
|
+
if (depth > MAX_DEPTH) return false;
|
|
125
|
+
const type = schemaType(schema);
|
|
126
|
+
if (type === "array") {
|
|
127
|
+
if (!Array.isArray(value)) return false;
|
|
128
|
+
const items = schema.items;
|
|
129
|
+
if (items === null || typeof items !== "object" || Array.isArray(items)) return true;
|
|
130
|
+
return value.every((element) => matchesSchema(element, items as Record<string, unknown>, depth + 1));
|
|
131
|
+
}
|
|
132
|
+
if (type === "object") {
|
|
133
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
|
|
134
|
+
const rec = value as Record<string, unknown>;
|
|
135
|
+
const props = schema.properties;
|
|
136
|
+
const declared = props !== null && typeof props === "object" && !Array.isArray(props) ? (props as Record<string, unknown>) : null;
|
|
137
|
+
if (Array.isArray(schema.required)) {
|
|
138
|
+
for (const key of schema.required) {
|
|
139
|
+
if (typeof key !== "string" || key in rec) continue;
|
|
140
|
+
// Strict-mode hosts list every key here and mark the optional ones nullable,
|
|
141
|
+
// so a nullable key that is absent is correct, not a violation.
|
|
142
|
+
if (declared !== null && isNullableProperty(declared[key])) continue;
|
|
143
|
+
return false;
|
|
87
144
|
}
|
|
88
|
-
return true;
|
|
89
145
|
}
|
|
90
|
-
if (
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
146
|
+
if (declared === null) return true;
|
|
147
|
+
for (const [key, entry] of Object.entries(rec)) {
|
|
148
|
+
const propSchema = declared[key];
|
|
149
|
+
if (propSchema === null || typeof propSchema !== "object" || Array.isArray(propSchema)) continue;
|
|
150
|
+
if (!matchesSchema(entry, propSchema as Record<string, unknown>, depth + 1)) return false;
|
|
151
|
+
}
|
|
94
152
|
return true;
|
|
153
|
+
}
|
|
154
|
+
if (type === "string") return typeof value === "string";
|
|
155
|
+
if (type === "number") return typeof value === "number" && Number.isFinite(value);
|
|
156
|
+
if (type === "boolean") return typeof value === "boolean";
|
|
157
|
+
if (Array.isArray(schema.enum)) return schema.enum.some((member) => member === value);
|
|
158
|
+
return true;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** The spellings free models use for "no value" when the slot is nullable. */
|
|
162
|
+
const NULL_SENTINELS = new Set(["null", "none", "undefined", "nil", "n/a"]);
|
|
163
|
+
|
|
164
|
+
function isNullSentinel(text: string): boolean {
|
|
165
|
+
return NULL_SENTINELS.has(text.trim().toLowerCase());
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** The non-null branch of a nullable declaration, or null when it is not one. */
|
|
169
|
+
function nonNullBranch(prop: Record<string, unknown>): Record<string, unknown> | null {
|
|
170
|
+
const anyOf = prop.anyOf;
|
|
171
|
+
if (!Array.isArray(anyOf)) return null;
|
|
172
|
+
for (const branch of anyOf) {
|
|
173
|
+
if (branch === null || typeof branch !== "object" || Array.isArray(branch)) continue;
|
|
174
|
+
const rec = branch as Record<string, unknown>;
|
|
175
|
+
if (rec.type === "null") continue;
|
|
176
|
+
return rec;
|
|
177
|
+
}
|
|
178
|
+
return null;
|
|
95
179
|
}
|
|
96
180
|
|
|
97
181
|
/**
|
|
@@ -164,6 +248,24 @@ function repairValue(
|
|
|
164
248
|
out[key] = entry;
|
|
165
249
|
continue;
|
|
166
250
|
}
|
|
251
|
+
// A strict host makes optional keys `anyOf: [T, null]`, and free models
|
|
252
|
+
// routinely answer that slot with the SENTINEL AS TEXT — `"timeout":"null"`,
|
|
253
|
+
// `"pty":"None"` (measured across 4 of 37 catalog models). The host rejects
|
|
254
|
+
// the call because a string is not a number and not null.
|
|
255
|
+
//
|
|
256
|
+
// Restricted to non-string inner types on purpose: when T is `string`,
|
|
257
|
+
// `"null"` could be a genuine value and rewriting it would destroy data.
|
|
258
|
+
// With T = number/boolean/object/array the property can never legitimately
|
|
259
|
+
// hold a bare string, so collapsing a null sentinel loses nothing and makes
|
|
260
|
+
// the call one the host accepts.
|
|
261
|
+
if (typeof entry === "string" && isNullSentinel(entry)) {
|
|
262
|
+
const nonNull = nonNullBranch(propSchema as Record<string, unknown>);
|
|
263
|
+
if (nonNull !== null && schemaType(nonNull) !== "string") {
|
|
264
|
+
out[key] = null;
|
|
265
|
+
changed = true;
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
167
269
|
const repaired = repairValue(entry, propSchema as Record<string, unknown>, depth + 1);
|
|
168
270
|
if (repaired.changed) changed = true;
|
|
169
271
|
out[key] = repaired.value;
|
|
@@ -318,28 +420,29 @@ function promoteStringArray(
|
|
|
318
420
|
|
|
319
421
|
/**
|
|
320
422
|
* Per-request map of caller tool name (lowercased) to the schema its
|
|
321
|
-
* arguments are repaired against.
|
|
322
|
-
*
|
|
323
|
-
*
|
|
423
|
+
* arguments are repaired against. Every tool that declares object parameters
|
|
424
|
+
* is kept, NOT only those with an object/array property: the prefix-glue
|
|
425
|
+
* salvage in {@link parseArguments} needs only the caller's own schema to
|
|
426
|
+
* confirm the recovered object, and it fires most often on scalar-only tools.
|
|
427
|
+
* `bash` is the live case — OMP's `bashSchemaBase` declares only `command`,
|
|
428
|
+
* `timeout`, `cwd`, `pty` (reference/oh-my-pi tools/bash.ts:327), so a
|
|
429
|
+
* structural-only filter left the most-used tool unrepairable and the host
|
|
430
|
+
* rejected the glued payload outright.
|
|
324
431
|
*
|
|
325
432
|
* Keyed by CALLER name, which is what arrives downstream after the casing and
|
|
326
433
|
* find->glob restores. Injected fingerprint placeholders are absent by
|
|
327
434
|
* construction, so they are never repaired.
|
|
328
435
|
*/
|
|
329
436
|
export function buildArgRepairSchemas(tools: unknown[]): Map<string, Record<string, unknown>> {
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
if (!hasStructural) continue;
|
|
340
|
-
schemas.set(canon.name.trim().toLowerCase(), canon.parameters);
|
|
341
|
-
}
|
|
342
|
-
return schemas;
|
|
437
|
+
const schemas = new Map<string, Record<string, unknown>>();
|
|
438
|
+
for (const tool of tools) {
|
|
439
|
+
const canon = canonicalizeTool(tool);
|
|
440
|
+
if (canon === null) continue;
|
|
441
|
+
const props = canon.parameters.properties;
|
|
442
|
+
if (props === null || typeof props !== "object" || Array.isArray(props)) continue;
|
|
443
|
+
schemas.set(canon.name.trim().toLowerCase(), canon.parameters);
|
|
444
|
+
}
|
|
445
|
+
return schemas;
|
|
343
446
|
}
|
|
344
447
|
|
|
345
448
|
|
|
@@ -374,8 +477,13 @@ function parseArguments(argsJson: string): { value: Record<string, unknown>; sal
|
|
|
374
477
|
} catch {
|
|
375
478
|
// fall through to the salvage scan
|
|
376
479
|
}
|
|
377
|
-
|
|
378
|
-
|
|
480
|
+
// Only a brace that OPENS a top-level object can start the caller's call: a
|
|
481
|
+
// brace nested inside an already-unterminated object belongs to that outer
|
|
482
|
+
// object, so promoting it would drop the declared key that wraps it
|
|
483
|
+
// (`{"opts":{"a":1` must not salvage to `{"a":1}`).
|
|
484
|
+
for (const start of topLevelObjectStarts(argsJson)) {
|
|
485
|
+
const candidate = argsJson.slice(start);
|
|
486
|
+
const i = start;
|
|
379
487
|
// Two shapes to try, in order: the candidate as written, then the candidate
|
|
380
488
|
// with its missing closers appended. The closer is only offered at the FIRST
|
|
381
489
|
// brace and only when something precedes it — that fragment is the evidence
|
|
@@ -395,7 +503,35 @@ function parseArguments(argsJson: string): { value: Record<string, unknown>; sal
|
|
|
395
503
|
}
|
|
396
504
|
}
|
|
397
505
|
}
|
|
398
|
-
|
|
506
|
+
return undefined;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* Offsets of every `{` that could open the caller's own object.
|
|
511
|
+
*
|
|
512
|
+
* Depth counts braces only, clamped at zero, and deliberately ignores string
|
|
513
|
+
* state. The junk in front is itself a cut fragment: it usually ends
|
|
514
|
+
* mid-string (`…/freelane-proxy"`), so string tracking would classify the whole
|
|
515
|
+
* real call as "inside a string" and find no start at all — which is exactly
|
|
516
|
+
* the live failure this scan has to recover from.
|
|
517
|
+
*
|
|
518
|
+
* A nested brace is a candidate only when no key precedes it: `{"opts":{"a":1}`
|
|
519
|
+
* must NOT promote its inner `{"a":1}` (a `:` sits between, so the outer
|
|
520
|
+
* object is a real one and its declared key would be dropped), while the
|
|
521
|
+
* observed prefix junk `}{` must still reach the real call that follows it.
|
|
522
|
+
*/
|
|
523
|
+
function topLevelObjectStarts(text: string): number[] {
|
|
524
|
+
const starts: number[] = [];
|
|
525
|
+
const openAt: number[] = [];
|
|
526
|
+
for (let i = 0; i < text.length; i++) {
|
|
527
|
+
if (text[i] === "{") {
|
|
528
|
+
if (openAt.length === 0 || !text.slice(openAt[openAt.length - 1] + 1, i).includes(":")) starts.push(i);
|
|
529
|
+
openAt.push(i);
|
|
530
|
+
continue;
|
|
531
|
+
}
|
|
532
|
+
if (text[i] === "}" && openAt.length > 0) openAt.pop();
|
|
533
|
+
}
|
|
534
|
+
return starts;
|
|
399
535
|
}
|
|
400
536
|
|
|
401
537
|
/**
|
|
@@ -404,11 +540,15 @@ function parseArguments(argsJson: string): { value: Record<string, unknown>; sal
|
|
|
404
540
|
*
|
|
405
541
|
* Only ever called on a candidate that starts at a `{` preceded by a fragment
|
|
406
542
|
* of the previous call, and only when the candidate's own contents already
|
|
407
|
-
* end cleanly.
|
|
543
|
+
* end cleanly. Three refusals keep this honest:
|
|
408
544
|
*
|
|
409
545
|
* - Cut inside a string literal (an unterminated `"`) returns undefined. The
|
|
410
546
|
* value itself is incomplete, so any closing brace would silently shorten
|
|
411
547
|
* it — and these arguments carry shell commands.
|
|
548
|
+
* - Cut inside a number literal returns undefined. `{"timeout":18` may have
|
|
549
|
+
* been `180`, `18.5`, or `18`; completing it hands the host a confident wrong
|
|
550
|
+
* value with nothing to signal the guess. (`timeout` is a declared number on
|
|
551
|
+
* both hosts' `bash`.)
|
|
412
552
|
* - Nothing left open returns undefined, so a well-formed candidate never
|
|
413
553
|
* reaches this path.
|
|
414
554
|
*
|
|
@@ -433,6 +573,8 @@ function appendClosers(text: string): string | undefined {
|
|
|
433
573
|
}
|
|
434
574
|
// Inside a string at the end: the value was cut mid-token, so refuse.
|
|
435
575
|
if (inString || open.length === 0) return undefined;
|
|
576
|
+
// A bare number at the end is a cut literal (`18` of `180`), so refuse.
|
|
577
|
+
if (CUT_NUMBER_TAIL.test(text)) return undefined;
|
|
436
578
|
return text + open.reverse().join("");
|
|
437
579
|
}
|
|
438
580
|
/**
|
package/src/tool-translation.ts
CHANGED
|
@@ -53,7 +53,17 @@ export const PI_TOOL_NAMES = [
|
|
|
53
53
|
"ls",
|
|
54
54
|
] as const;
|
|
55
55
|
|
|
56
|
-
/**
|
|
56
|
+
/**
|
|
57
|
+
* OMP host built-ins: BUILTIN_TOOL_NAMES in
|
|
58
|
+
* reference/oh-my-pi/packages/coding-agent/src/tools/builtin-names.ts, plus
|
|
59
|
+
* browser/computer (real top-level tools in the same checkout) and `hub`.
|
|
60
|
+
*
|
|
61
|
+
* `find` here is OMP's semantic search (reference/oh-my-pi
|
|
62
|
+
* tools/jfind/index.ts:54), NOT Pi's file finder: they are different tools
|
|
63
|
+
* with different schemas, and both are declared together whenever the session
|
|
64
|
+
* enables it. That is why the translator renames `find` to `glob` only when the
|
|
65
|
+
* caller left the `glob` slot empty — see translateToolsForPath.
|
|
66
|
+
*/
|
|
57
67
|
export const OMP_TOOL_NAMES = [
|
|
58
68
|
"read",
|
|
59
69
|
"bash",
|
|
@@ -62,15 +72,20 @@ export const OMP_TOOL_NAMES = [
|
|
|
62
72
|
"ast_edit",
|
|
63
73
|
"ask",
|
|
64
74
|
"debug",
|
|
75
|
+
"ida",
|
|
65
76
|
"eval",
|
|
66
77
|
"github",
|
|
67
78
|
"glob",
|
|
68
79
|
"grep",
|
|
80
|
+
"find",
|
|
69
81
|
"lsp",
|
|
70
82
|
"checkpoint",
|
|
71
83
|
"rewind",
|
|
84
|
+
"context_notes",
|
|
85
|
+
"new_context",
|
|
72
86
|
"security_scan",
|
|
73
87
|
"task",
|
|
88
|
+
"wait",
|
|
74
89
|
"hub",
|
|
75
90
|
"todo",
|
|
76
91
|
"web_search",
|
|
@@ -180,13 +195,20 @@ export const OMP_CALLER_MARKERS = ["ask", "task", "todo", "hub", "lsp"] as const
|
|
|
180
195
|
|
|
181
196
|
/**
|
|
182
197
|
* True when a lowercased caller-tool name set looks like OMP: any of
|
|
183
|
-
* ask/task/todo/hub/lsp present, or glob present
|
|
184
|
-
*
|
|
198
|
+
* ask/task/todo/hub/lsp present, or `glob` present at all.
|
|
199
|
+
*
|
|
200
|
+
* `glob` alone is the discriminator because Pi has no such tool (its file
|
|
201
|
+
* finder is `find`, reference/pi core/tools/index.ts:95) while OMP declares
|
|
202
|
+
* both. The old test was "glob without find", which assumed `find` was
|
|
203
|
+
* Pi-exclusive; OMP ships a semantic `find` too (reference/oh-my-pi
|
|
204
|
+
* tools/jfind/index.ts:54), so an OMP read-only session carrying glob+find was
|
|
205
|
+
* classified as Pi and its fingerprint slots came back as non-executable
|
|
206
|
+
* placeholders. Case-insensitive; pass already-lowercased names.
|
|
185
207
|
*/
|
|
186
208
|
export function isOmpLikeCaller(lowerNames: Iterable<string>): boolean {
|
|
187
209
|
const set = lowerNames instanceof Set ? lowerNames : new Set(lowerNames);
|
|
188
210
|
for (const m of OMP_CALLER_MARKERS) if (set.has(m)) return true;
|
|
189
|
-
return set.has("glob")
|
|
211
|
+
return set.has("glob");
|
|
190
212
|
}
|
|
191
213
|
|
|
192
214
|
export interface CanonicalTool {
|
|
@@ -385,8 +407,14 @@ export function sanitizeUpstreamToolName(name: string, taken: ReadonlySet<string
|
|
|
385
407
|
}
|
|
386
408
|
/** Inspect the original caller tools (pre-translate) for the rewrite cases. */
|
|
387
409
|
export function buildFindGlobRestore(callerTools: unknown[]): FindGlobRestore {
|
|
388
|
-
|
|
389
|
-
|
|
410
|
+
// Same rule as translateToolsForPath: the find->glob rename fires only when
|
|
411
|
+
// the caller left the `glob` slot empty. OMP declares both real tools, so
|
|
412
|
+
// there the rename must not happen and `find` must keep its own name.
|
|
413
|
+
const callerDeclaresGlob = callerTools.some((tool) => {
|
|
414
|
+
const canon = canonicalizeTool(tool);
|
|
415
|
+
return canon !== null && canon.name.trim().toLowerCase() === "glob";
|
|
416
|
+
});
|
|
417
|
+
const nameFor = (name: string): string => (callerDeclaresGlob ? name.trim() : upstreamToolNameFor(name));
|
|
390
418
|
const renamed: Record<string, string> = {};
|
|
391
419
|
// Same first-seen-wins order and case rules as translateToolsForPath, so
|
|
392
420
|
// the recorded map keys match the names that actually reach upstream.
|
|
@@ -395,25 +423,28 @@ export function buildFindGlobRestore(callerTools: unknown[]): FindGlobRestore {
|
|
|
395
423
|
const canon = canonicalizeTool(tool);
|
|
396
424
|
if (!canon) continue;
|
|
397
425
|
const callerName = canon.name;
|
|
398
|
-
|
|
399
|
-
else if (callerName.trim().toLowerCase() === "glob") sawGlob = true;
|
|
426
|
+
const upstreamName = nameFor(callerName);
|
|
400
427
|
// Mirrors translateToolsForPath: dedup on the lowercased
|
|
401
428
|
// pre-sanitization name (a collapsed duplicate never reaches upstream),
|
|
402
429
|
// then sanitize with the caller's casing intact.
|
|
403
|
-
const dedupKey =
|
|
430
|
+
const dedupKey = upstreamName.toLowerCase();
|
|
404
431
|
if (seen.has(dedupKey)) continue;
|
|
405
432
|
seen.add(dedupKey);
|
|
406
|
-
const sanitized = sanitizeUpstreamToolName(
|
|
433
|
+
const sanitized = sanitizeUpstreamToolName(upstreamName, seen);
|
|
407
434
|
if (sanitized.toLowerCase() !== dedupKey) seen.add(sanitized.toLowerCase());
|
|
408
435
|
// Only character-folding rewrites belong in the sanitized map. The
|
|
409
|
-
// find->glob rename is a
|
|
410
|
-
//
|
|
411
|
-
//
|
|
436
|
+
// find->glob rename is a single-name fallback, so a name that came in
|
|
437
|
+
// as `find` must not be recorded here: upstream `glob` may be the
|
|
438
|
+
// caller's own glob.
|
|
412
439
|
if (sanitized !== callerName && !isFindToolName(callerName)) {
|
|
413
440
|
renamed[sanitized.toLowerCase()] = callerName;
|
|
414
441
|
}
|
|
415
442
|
}
|
|
416
|
-
const
|
|
443
|
+
const sawFind = callerTools.some((tool) => {
|
|
444
|
+
const canon = canonicalizeTool(tool);
|
|
445
|
+
return canon !== null && isFindToolName(canon.name);
|
|
446
|
+
});
|
|
447
|
+
const restored: FindGlobRestore = { renamedFindToGlob: sawFind && !callerDeclaresGlob, renamed };
|
|
417
448
|
return restored;
|
|
418
449
|
}
|
|
419
450
|
/**
|
|
@@ -489,22 +520,46 @@ function carryExtraFields(
|
|
|
489
520
|
}
|
|
490
521
|
}
|
|
491
522
|
/**
|
|
492
|
-
* Reshape a caller tool array for the target path. Tools already in the
|
|
493
|
-
*
|
|
494
|
-
*
|
|
495
|
-
*
|
|
496
|
-
*
|
|
497
|
-
*
|
|
498
|
-
*
|
|
499
|
-
*
|
|
500
|
-
*
|
|
501
|
-
*
|
|
502
|
-
* `Bash` never duplicates `bash` and `find` collapses with `glob`.
|
|
503
|
-
* Re-translating translated output adds zero tools. Never returns null entries.
|
|
523
|
+
* Reshape a caller tool array for the target path. Tools already in the target
|
|
524
|
+
* shape pass through verbatim (identical reference, every extra field intact).
|
|
525
|
+
* Function tools in another shape are converted, carrying
|
|
526
|
+
* `description`/`parameters`/`strict` verbatim plus every other top-level
|
|
527
|
+
* caller field except the wire-shape-forbidden keys (`type`/`function`
|
|
528
|
+
* wrapper, `name`, `description`, `parameters`/`input_schema`), which are
|
|
529
|
+
* remapped per target shape. Never injects `additionalProperties`. Every other
|
|
530
|
+
* entry passes through verbatim. Duplicates collapse first-seen-wins on the
|
|
531
|
+
* lowercased upstream name, so `Bash` never duplicates `bash`. Re-translating
|
|
532
|
+
* translated output adds zero tools. Never returns null entries.
|
|
504
533
|
* `tool_choice` is never imposed here; use the retarget helpers to carry it.
|
|
534
|
+
*
|
|
535
|
+
* Names ride the caller's own unless `forFingerprint` is set — that flag exists
|
|
536
|
+
* for the OpenCode free tier alone, whose gate expects the sextet name `glob`
|
|
537
|
+
* and therefore needs Pi's `find` renamed into it. On every other lane the
|
|
538
|
+
* rename is pure loss: the upstream would have accepted `find`, and the host
|
|
539
|
+
* gets `find` back and dispatches it natively.
|
|
505
540
|
*/
|
|
506
|
-
export function translateToolsForPath(tools: unknown[], pathname: string): Record<string, unknown>[] {
|
|
541
|
+
export function translateToolsForPath(tools: unknown[], pathname: string, opts?: { forFingerprint?: boolean }): Record<string, unknown>[] {
|
|
507
542
|
const api = apiForPathname(pathname);
|
|
543
|
+
// Only the OpenCode free tier needs a tool renamed: its gate looks for a
|
|
544
|
+
// sextet whose `glob` slot Pi's `find` can fill. Every other upstream accepts
|
|
545
|
+
// the caller's own names, so on those lanes the name rides verbatim in BOTH
|
|
546
|
+
// directions and the host dispatches it natively — nothing to translate, and
|
|
547
|
+
// nothing to restore.
|
|
548
|
+
//
|
|
549
|
+
// Even on the fingerprint lane the rename is skipped when the caller declared
|
|
550
|
+
// `glob` itself: OMP declares both `glob` (file patterns) and `find`
|
|
551
|
+
// (semantic search, reference/oh-my-pi tools/jfind/index.ts:54), and renaming
|
|
552
|
+
// there would collapse two real tools into one under the other's schema.
|
|
553
|
+
let callerDeclaresGlob = false;
|
|
554
|
+
for (const tool of tools) {
|
|
555
|
+
const canon = canonicalizeTool(tool);
|
|
556
|
+
if (canon && canon.name.trim().toLowerCase() === "glob") {
|
|
557
|
+
callerDeclaresGlob = true;
|
|
558
|
+
break;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
const renameFind = opts?.forFingerprint === true && !callerDeclaresGlob;
|
|
562
|
+
const upstreamNameFor = (name: string): string => (renameFind ? upstreamToolNameFor(name) : name.trim());
|
|
508
563
|
const seen = new Set<string>();
|
|
509
564
|
const out: Record<string, unknown>[] = [];
|
|
510
565
|
for (const tool of tools) {
|
|
@@ -517,15 +572,16 @@ export function translateToolsForPath(tools: unknown[], pathname: string): Recor
|
|
|
517
572
|
continue;
|
|
518
573
|
}
|
|
519
574
|
// Duplicate collapse stays first-seen-wins on the lowercased upstream name
|
|
520
|
-
// (`Bash` never duplicates `bash`, `find` collapses with `glob`)
|
|
521
|
-
// the pre-sanitization name: the emitted name keeps the caller's
|
|
522
|
-
// the sanitizer only ever folds characters the upstream shape
|
|
523
|
-
|
|
575
|
+
// (`Bash` never duplicates `bash`, a Pi `find` still collapses with `glob`)
|
|
576
|
+
// and runs on the pre-sanitization name: the emitted name keeps the caller's
|
|
577
|
+
// casing, and the sanitizer only ever folds characters the upstream shape
|
|
578
|
+
// forbids.
|
|
579
|
+
const dedupKey = upstreamNameFor(canon.name).toLowerCase();
|
|
524
580
|
if (seen.has(dedupKey)) continue;
|
|
525
581
|
seen.add(dedupKey);
|
|
526
582
|
// The find->glob rename and the name sanitization share one upstream name,
|
|
527
583
|
// so a caller tool needing either is emitted under a single new name.
|
|
528
|
-
const upstreamName = sanitizeUpstreamToolName(
|
|
584
|
+
const upstreamName = sanitizeUpstreamToolName(upstreamNameFor(canon.name), seen);
|
|
529
585
|
if (upstreamName.toLowerCase() !== dedupKey) seen.add(upstreamName.toLowerCase());
|
|
530
586
|
const needsRename = upstreamName !== canon.name;
|
|
531
587
|
if (!needsRename && alreadyTargetShape(rec, api)) {
|
|
@@ -535,60 +591,17 @@ export function translateToolsForPath(tools: unknown[], pathname: string): Recor
|
|
|
535
591
|
const renamed: CanonicalTool = needsRename
|
|
536
592
|
? { ...canon, name: upstreamName }
|
|
537
593
|
: canon;
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
594
|
+
const converted =
|
|
595
|
+
api === "responses"
|
|
596
|
+
? toResponsesTool(renamed)
|
|
597
|
+
: api === "messages"
|
|
598
|
+
? toAnthropicTool(renamed)
|
|
599
|
+
: toChatTool(renamed);
|
|
600
|
+
carryExtraFields(rec, converted);
|
|
601
|
+
out.push(converted);
|
|
546
602
|
}
|
|
547
603
|
return out;
|
|
548
604
|
}
|
|
549
|
-
/**
|
|
550
|
-
* Inject the missing fingerprint tools into an already-translated tool array,
|
|
551
|
-
* using the target path's shape. Idempotent and case-insensitive: `Bash`
|
|
552
|
-
* satisfies `bash` and is never duplicated.
|
|
553
|
-
* OMP-like callers (explicit `ompLike`, else auto-detected from the array via
|
|
554
|
-
* {@link isOmpLikeCaller}) get REAL minimal executable definitions from
|
|
555
|
-
* {@link OMP_FINGERPRINT_DEFS}; everyone else (Pi) keeps the empty COMPAT
|
|
556
|
-
* placeholders. `edit` is always the COMPAT placeholder, even for OMP-like
|
|
557
|
-
* callers — no static edit def executes on either host, so it must stay
|
|
558
|
-
* cloaked downstream. Re-injecting injected output adds zero tools.
|
|
559
|
-
*/
|
|
560
|
-
export function injectFingerprintTools(
|
|
561
|
-
tools: Record<string, unknown>[],
|
|
562
|
-
pathname: string,
|
|
563
|
-
ompLike?: boolean,
|
|
564
|
-
): Record<string, unknown>[] {
|
|
565
|
-
const present = new Set<string>();
|
|
566
|
-
for (const tool of tools) {
|
|
567
|
-
const canon = canonicalizeTool(tool);
|
|
568
|
-
if (canon) present.add(canon.name.trim().toLowerCase());
|
|
569
|
-
}
|
|
570
|
-
const omp = ompLike ?? isOmpLikeCaller(present);
|
|
571
|
-
const api = apiForPathname(pathname);
|
|
572
|
-
for (const name of OPENCODE_FINGERPRINT_TOOLS) {
|
|
573
|
-
if (present.has(name.toLowerCase())) continue;
|
|
574
|
-
const def = omp && name !== "edit" ? OMP_FINGERPRINT_DEFS[name] : null;
|
|
575
|
-
const canon: CanonicalTool = {
|
|
576
|
-
name,
|
|
577
|
-
description: def ? def.description : COMPAT_TOOL_DESCRIPTION,
|
|
578
|
-
parameters: def ? { ...(def.parameters as Record<string, unknown>) } : { type: "object", properties: {} },
|
|
579
|
-
};
|
|
580
|
-
tools.push(
|
|
581
|
-
api === "responses"
|
|
582
|
-
? toResponsesTool(canon)
|
|
583
|
-
: api === "messages"
|
|
584
|
-
? toAnthropicTool(canon)
|
|
585
|
-
: toChatTool(canon),
|
|
586
|
-
);
|
|
587
|
-
present.add(name.toLowerCase());
|
|
588
|
-
}
|
|
589
|
-
return tools;
|
|
590
|
-
}
|
|
591
|
-
|
|
592
605
|
/**
|
|
593
606
|
* Responses <-> Chat body translation. Cline serves chat completions only, so
|
|
594
607
|
* the responses body is reshaped to chat before the Cline branch sends it,
|
|
@@ -675,7 +688,10 @@ export function chatBodyFromResponsesBody(body: Record<string, unknown>): Record
|
|
|
675
688
|
out.messages = messages;
|
|
676
689
|
if (Array.isArray(body.tools)) out.tools = translateToolsForPath(body.tools, "/v1/chat/completions");
|
|
677
690
|
if (body.tool_choice !== undefined) out.tool_choice = body.tool_choice;
|
|
678
|
-
|
|
691
|
+
// `max_output_tokens` is the Responses-native cap and is what a host actually
|
|
692
|
+
// sets on this path; without it here the cap silently vanished on the
|
|
693
|
+
// chat-only lanes and the model answered at its own default length.
|
|
694
|
+
for (const key of ["temperature", "top_p", "max_tokens", "max_completion_tokens", "max_output_tokens", "stop", "presence_penalty", "frequency_penalty", "seed", "user", "parallel_tool_calls", "response_format"] as const) {
|
|
679
695
|
if (body[key] !== undefined) out[key] = body[key];
|
|
680
696
|
}
|
|
681
697
|
return out;
|