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 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.0",
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: "completed",
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 sseText;
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
- convertSseToJson,
39
- enforceOpencodeFingerprint,
40
- sseToChatCompletionJson,
41
- type CaseRestoreMap,
42
- type FindGlobRestore,
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
- chatResponsesJsonFromChatCompletion,
51
- chatResponsesSseFromChatCompletion,
52
- chatBodyFromResponsesBody,
53
- translateToolsForPath,
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
- const data = convertSseToJson(sseText, pathname, true, undefined, undefined, undefined, argSchemas);
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, undefined, undefined, argSchemas);
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
- const data = convertSseToJson(sseText, pathname, true, undefined, undefined, undefined, argSchemas);
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, undefined, undefined, argSchemas);
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";
@@ -15,7 +15,7 @@ import {
15
15
  WATCHDOG_SSE_MIN_SAMPLES,
16
16
  WATCHDOG_SSE_WINDOW,
17
17
  } from "./config.ts";
18
- import type { CaseRestoreMap, FindGlobRestore } from "./opencode-fingerprint.ts";
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
- * Two families are mechanically decidable from the CALLER's own declared
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
- /** A schema property worth repairing: it holds an object or an array. */
53
- function structuralProperty(schema: Record<string, unknown>): boolean {
54
- const type = schemaType(schema);
55
- return type === "object" || type === "array";
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
- if (depth > MAX_DEPTH) return false;
66
- const type = schemaType(schema);
67
- if (type === "array") {
68
- if (!Array.isArray(value)) return false;
69
- const items = schema.items;
70
- if (items === null || typeof items !== "object" || Array.isArray(items)) return true;
71
- return value.every((element) => matchesSchema(element, items as Record<string, unknown>, depth + 1));
72
- }
73
- if (type === "object") {
74
- if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
75
- const rec = value as Record<string, unknown>;
76
- if (Array.isArray(schema.required)) {
77
- for (const key of schema.required) {
78
- if (typeof key === "string" && !(key in rec)) return false;
79
- }
80
- }
81
- const props = schema.properties;
82
- if (props === null || typeof props !== "object" || Array.isArray(props)) return true;
83
- for (const [key, entry] of Object.entries(rec)) {
84
- const propSchema = (props as Record<string, unknown>)[key];
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 (type === "string") return typeof value === "string";
91
- if (type === "number") return typeof value === "number" && Number.isFinite(value);
92
- if (type === "boolean") return typeof value === "boolean";
93
- if (Array.isArray(schema.enum)) return schema.enum.some((member) => member === value);
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. Only tools whose parameters declare at least
322
- * one object or array property are kept: those are the only ones a repair can
323
- * act on, and an empty map means the request streams byte-identically.
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
- const schemas = new Map<string, Record<string, unknown>>();
331
- for (const tool of tools) {
332
- const canon = canonicalizeTool(tool);
333
- if (canon === null) continue;
334
- const props = canon.parameters.properties;
335
- if (props === null || typeof props !== "object" || Array.isArray(props)) continue;
336
- const hasStructural = Object.values(props as Record<string, unknown>).some(
337
- (prop) => prop !== null && typeof prop === "object" && !Array.isArray(prop) && structuralProperty(prop as Record<string, unknown>),
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
- for (let i = argsJson.indexOf("{"); i !== -1; i = argsJson.indexOf("{", i + 1)) {
378
- const candidate = argsJson.slice(i);
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
- return undefined;
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. Two refusals keep this honest:
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
  /**
@@ -53,7 +53,17 @@ export const PI_TOOL_NAMES = [
53
53
  "ls",
54
54
  ] as const;
55
55
 
56
- /** OMP host built-ins: BUILTIN_TOOL_NAMES in reference/oh-my-pi packages/coding-agent/src/tools/builtin-names.ts plus browser/computer */
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 without find (Pi sends find,
184
- * OMP sends glob). Case-insensitive; pass already-lowercased names.
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") && !set.has("find");
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
- let sawFind = false;
389
- let sawGlob = false;
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
- if (isFindToolName(callerName)) sawFind = true;
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 = upstreamToolNameFor(callerName).toLowerCase();
430
+ const dedupKey = upstreamName.toLowerCase();
404
431
  if (seen.has(dedupKey)) continue;
405
432
  seen.add(dedupKey);
406
- const sanitized = sanitizeUpstreamToolName(upstreamToolNameFor(callerName), seen);
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 collision case with its own flag: when the
410
- // caller also sent `glob`, upstream `glob` really is the caller's glob,
411
- // so recording it here would misroute a genuine call.
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 restored: FindGlobRestore = { renamedFindToGlob: sawFind && !sawGlob, renamed };
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
- * target shape pass through verbatim (identical reference, every extra field
494
- * intact) except Pi `find`, which is renamed to `glob` even when the shape
495
- * already matches (new object, params/description verbatim). Function tools
496
- * in another shape are converted, carrying `description`/`parameters`/`strict`
497
- * verbatim plus every other top-level caller field except the
498
- * wire-shape-forbidden keys (`type`/`function` wrapper, `name`, `description`,
499
- * `parameters`/`input_schema`), which are remapped per target shape. Never
500
- * injects `additionalProperties`. Every other entry passes through verbatim.
501
- * Duplicates collapse first-seen-wins on the lowercased upstream name, so
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`) and runs on
521
- // the pre-sanitization name: the emitted name keeps the caller's casing, and
522
- // the sanitizer only ever folds characters the upstream shape forbids.
523
- const dedupKey = upstreamToolNameFor(canon.name).toLowerCase();
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(upstreamToolNameFor(canon.name), seen);
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
- const converted =
539
- api === "responses"
540
- ? toResponsesTool(renamed)
541
- : api === "messages"
542
- ? toAnthropicTool(renamed)
543
- : toChatTool(renamed);
544
- carryExtraFields(rec, converted);
545
- out.push(converted);
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
- for (const key of ["temperature", "top_p", "max_tokens", "max_completion_tokens", "stop", "presence_penalty", "frequency_penalty", "seed", "user", "parallel_tool_calls", "response_format"] as const) {
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;