pi-freeflow 1.33.1 → 1.33.3

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,22 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.33.3
4
+
5
+ ### Patch Changes
6
+
7
+ - Fix tool calls being rejected with an invalid-arguments error when a model makes several calls in one turn. Some models label every parallel call `0`, so the pieces of different calls were joined together and arrived as one unreadable blob — a planning tool could then reject every update, even a minimal one, and leave its task list stuck mid-turn. Calls are now kept apart by their own identifier, and a call whose text arrives in several pieces still reassembles whole.
8
+
9
+ ## 1.33.2
10
+
11
+ ### Patch Changes
12
+
13
+ - Document how the OpenCode free tier decides whether to answer a request: which request properties open the gate, which commonly repeated assumptions about it are wrong, and how to reproduce a check locally.
14
+ - Fix tool calls breaking down on free models.
15
+
16
+ When a request carries a full set of agent tools, free models often return tool arguments that do not match the tool's declared shape. A planning tool asked for phased tasks could come back with the phases buried inside a JSON-encoded list under the wrong field, wrapped in an extra object, or under names the tool never declared — so the host rendered each phase as one long raw JSON string instead of real task lines.
17
+
18
+ The proxy now checks returned arguments against the tool schema the caller declared and repairs them when the intended shape is unambiguous, across all three API shapes, streamed and non-streamed, and for every supported model provider. Well-formed calls, calls for tools without structured parameters, and arguments that were cut off mid-stream all pass through untouched.
19
+
3
20
  ## 1.33.1
4
21
 
5
22
  ### Patch Changes
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-freeflow",
3
3
  "type": "module",
4
- "version": "1.33.1",
4
+ "version": "1.33.3",
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",
@@ -38,6 +38,7 @@ import {
38
38
  type FindGlobRestore,
39
39
  type FingerprintToolName,
40
40
  } from "./tool-translation.ts";
41
+ import { buildArgRepairSchemas, repairToolArguments, repairToolArgumentsValue } from "./tool-args.ts";
41
42
  import { getModelDef } from "./models.ts";
42
43
  import type { ThinkingLevel } from "./types.ts";
43
44
 
@@ -358,6 +359,11 @@ export function normalizeResponsesBody(body: Record<string, unknown>): void {
358
359
  * for downstream cloaking. Cloaking strips by `injected` only: names in
359
360
  * `injectedReal` were served with executable definitions, so model calls to
360
361
  * them execute downstream instead of being cloaked.
362
+ *
363
+ * `argSchemas` (lowercased caller tool name -> declared parameters) lets
364
+ * downstream repair malformed arguments against the caller's own schema; it
365
+ * is empty when no caller tool declares a structured parameter, so such
366
+ * requests keep streaming byte-identically.
361
367
  */
362
368
  export function enforceOpencodeFingerprint(
363
369
  body: Record<string, unknown>,
@@ -370,6 +376,7 @@ export function enforceOpencodeFingerprint(
370
376
  findGlob: FindGlobRestore;
371
377
  injected: string[];
372
378
  injectedReal: string[];
379
+ argSchemas: Map<string, Record<string, unknown>>;
373
380
  } {
374
381
  const clientRequestedStream = body.stream === true;
375
382
  const callerHadTools = Array.isArray(body.tools) && body.tools.length > 0;
@@ -398,6 +405,10 @@ export function enforceOpencodeFingerprint(
398
405
  if (body.tool_choice !== undefined) {
399
406
  body.tool_choice = retargetToolChoiceForUpstream(body.tool_choice, findGlob) as Record<string, unknown> | string;
400
407
  }
408
+ // Captured before translation and injection: the map is keyed by CALLER tool
409
+ // name (what arrives downstream), and injected placeholders must not be
410
+ // repairable.
411
+ const argSchemas = buildArgRepairSchemas(Array.isArray(body.tools) ? body.tools : []);
401
412
  const callerUpstream = new Set<string>();
402
413
  const callerNames = new Set<string>();
403
414
  if (Array.isArray(body.tools)) {
@@ -440,7 +451,7 @@ export function enforceOpencodeFingerprint(
440
451
  // Empty-placeholder callers stay silent via the COMPAT description and the
441
452
  // output-aggregator; real OMP definitions execute normally.
442
453
 
443
- return { clientRequestedStream, callerHadTools, addedTools: after > before, caseRestore, findGlob, injected, injectedReal };
454
+ return { clientRequestedStream, callerHadTools, addedTools: after > before, caseRestore, findGlob, injected, injectedReal, argSchemas };
444
455
  }
445
456
 
446
457
  /**
@@ -497,6 +508,7 @@ function cloakChatMessage(
497
508
  caseRestore?: CaseRestoreMap,
498
509
  findGlob?: FindGlobRestore,
499
510
  injected?: readonly string[],
511
+ argSchemas?: ReadonlyMap<string, Record<string, unknown>>,
500
512
  ): void {
501
513
  if (!msg || typeof msg !== "object" || Array.isArray(msg)) return;
502
514
  const m = msg as Record<string, unknown>;
@@ -524,11 +536,9 @@ function cloakChatMessage(
524
536
  if (tc && typeof tc === "object" && !Array.isArray(tc)) {
525
537
  const fn = (tc as Record<string, unknown>).function;
526
538
  if (fn && typeof fn === "object" && !Array.isArray(fn) && typeof (fn as Record<string, unknown>).name === "string") {
527
- (fn as Record<string, unknown>).name = restoreCallerName(
528
- (fn as Record<string, unknown>).name as string,
529
- caseRestore,
530
- findGlob,
531
- );
539
+ const f = fn as Record<string, unknown>;
540
+ f.name = restoreCallerName(f.name as string, caseRestore, findGlob);
541
+ f.arguments = repairToolArguments(f.name as string, typeof f.arguments === "string" ? f.arguments : "", argSchemas);
532
542
  }
533
543
  }
534
544
  kept.push(tc);
@@ -543,6 +553,7 @@ export function sseToChatCompletionJson(
543
553
  caseRestore?: CaseRestoreMap,
544
554
  findGlob?: FindGlobRestore,
545
555
  injected?: readonly string[],
556
+ argSchemas?: ReadonlyMap<string, Record<string, unknown>>,
546
557
  ): Record<string, unknown> {
547
558
  const events = parseSseEvents(sseText);
548
559
  let id = "chatcmpl-freeflow";
@@ -553,10 +564,16 @@ export function sseToChatCompletionJson(
553
564
  let reasoningContent = "";
554
565
  let role = "assistant";
555
566
  let usage: unknown = undefined;
567
+ // Keyed by tool-call id whenever the provider sends one. Index is NOT
568
+ // trustworthy: free models routinely label every parallel call `index: 0`,
569
+ // and merging their fragments produces one unparseable argument blob
570
+ // (observed live: `{"i":"a","op":"done"}{"i":"b","op":"done"}`). Index is
571
+ // only the fallback for providers that omit an id entirely.
556
572
  const toolCallsMap = new Map<
557
- number,
573
+ string,
558
574
  { id: string; type: string; function: { name: string; arguments: string } }
559
575
  >();
576
+ const keyByIndex = new Map<number, string>();
560
577
 
561
578
  for (const ev of events) {
562
579
  if (ev.data === "[DONE]") continue;
@@ -580,8 +597,15 @@ export function sseToChatCompletionJson(
580
597
  }
581
598
  if (Array.isArray(delta.tool_calls)) {
582
599
  for (const tc of delta.tool_calls) {
583
- const idx = tc.index ?? 0;
584
- const existing = toolCallsMap.get(idx) ?? {
600
+ // The first delta of a call carries its id; later deltas carry only
601
+ // `arguments`. A later id-less delta therefore belongs to whatever
602
+ // call this index already opened.
603
+ const idx = typeof tc.index === "number" ? tc.index : 0;
604
+ const hasId = typeof tc.id === "string" && tc.id !== "";
605
+ let key = hasId ? `id:${tc.id}` : keyByIndex.get(idx);
606
+ if (key === undefined) key = `idx:${idx}`;
607
+ if (hasId) keyByIndex.set(idx, key);
608
+ const existing = toolCallsMap.get(key) ?? {
585
609
  id: tc.id || "",
586
610
  type: tc.type || "function",
587
611
  function: { name: "", arguments: "" },
@@ -590,13 +614,13 @@ export function sseToChatCompletionJson(
590
614
  if (tc.type) existing.type = tc.type;
591
615
  if (tc.function?.name) existing.function.name += tc.function.name;
592
616
  if (tc.function?.arguments) existing.function.arguments += tc.function.arguments;
593
- toolCallsMap.set(idx, existing);
617
+ toolCallsMap.set(key, existing);
594
618
  }
595
619
  }
596
620
  }
597
621
  // Choice contains a pre-assembled message: cloak before returning.
598
622
  if (c.message) {
599
- cloakChatMessage(c.message, callerHadTools, caseRestore, findGlob, injected);
623
+ cloakChatMessage(c.message, callerHadTools, caseRestore, findGlob, injected, argSchemas);
600
624
  return parsed as Record<string, unknown>;
601
625
  }
602
626
  }
@@ -606,9 +630,9 @@ export function sseToChatCompletionJson(
606
630
  }
607
631
  }
608
632
 
609
- const toolCalls = Array.from(toolCallsMap.entries())
610
- .sort(([a], [b]) => a - b)
611
- .map(([, tc]) => tc);
633
+ // Map insertion order is emission order, which is what the host expects. The
634
+ // previous numeric sort assumed the keys were indices; they are now ids.
635
+ const toolCalls = Array.from(toolCallsMap.values());
612
636
 
613
637
  const message: Record<string, unknown> = {
614
638
  role,
@@ -625,7 +649,10 @@ export function sseToChatCompletionJson(
625
649
  const kept = injected === undefined
626
650
  ? toolCalls
627
651
  : toolCalls.filter((tc) => !injected.includes(tc.function.name.toLowerCase()));
628
- for (const tc of kept) tc.function.name = restoreCallerName(tc.function.name, caseRestore, findGlob);
652
+ for (const tc of kept) {
653
+ tc.function.name = restoreCallerName(tc.function.name, caseRestore, findGlob);
654
+ tc.function.arguments = repairToolArguments(tc.function.name, tc.function.arguments, argSchemas);
655
+ }
629
656
  if (kept.length > 0) message.tool_calls = kept;
630
657
  }
631
658
 
@@ -650,9 +677,17 @@ export function sseToChatCompletionJson(
650
677
  * place. Only names this request injected are removed; caller calls keep
651
678
  * restored names (caller casing, upstream glob back to caller find). Tool-less
652
679
  * callers never see function_call items: every call is dropped, never leaked.
653
- * Ids and arguments ride verbatim (only the name is ever rewritten).
680
+ * Ids ride verbatim; arguments are repaired against the caller's declared
681
+ * schema when it says the emitted shape cannot be right.
654
682
  */
655
- function cloakResponsesObject(resp: unknown, callerHadTools = true, caseRestore?: CaseRestoreMap, findGlob?: FindGlobRestore, injected?: readonly string[]): void {
683
+ function cloakResponsesObject(
684
+ resp: unknown,
685
+ callerHadTools = true,
686
+ caseRestore?: CaseRestoreMap,
687
+ findGlob?: FindGlobRestore,
688
+ injected?: readonly string[],
689
+ argSchemas?: ReadonlyMap<string, Record<string, unknown>>,
690
+ ): void {
656
691
  if (!resp || typeof resp !== "object" || Array.isArray(resp)) return;
657
692
  const output = (resp as Record<string, unknown>).output;
658
693
  if (!Array.isArray(output)) return;
@@ -663,7 +698,11 @@ function cloakResponsesObject(resp: unknown, callerHadTools = true, caseRestore?
663
698
  if (rec.type === "function_call") {
664
699
  if (typeof rec.name === "string" && injected !== undefined && injected.includes(rec.name.toLowerCase())) continue;
665
700
  if (!callerHadTools) continue;
666
- if (typeof rec.name === "string") rec.name = restoreCallerName(rec.name, caseRestore, findGlob);
701
+ if (typeof rec.name === "string") {
702
+ const name = restoreCallerName(rec.name, caseRestore, findGlob);
703
+ rec.name = name;
704
+ rec.arguments = repairToolArguments(name, typeof rec.arguments === "string" ? rec.arguments : "", argSchemas);
705
+ }
667
706
  }
668
707
  }
669
708
  kept.push(item);
@@ -681,6 +720,7 @@ export function sseToResponsesJson(
681
720
  caseRestore?: CaseRestoreMap,
682
721
  findGlob?: FindGlobRestore,
683
722
  injected?: readonly string[],
723
+ argSchemas?: ReadonlyMap<string, Record<string, unknown>>,
684
724
  ): Record<string, unknown> {
685
725
  const events = parseSseEvents(sseText);
686
726
  // 1. Highest fidelity: response.completed event carries the final full response object
@@ -689,7 +729,7 @@ export function sseToResponsesJson(
689
729
  try {
690
730
  const parsed = JSON.parse(ev.data);
691
731
  if (parsed?.type === "response.completed" && parsed.response && typeof parsed.response === "object") {
692
- cloakResponsesObject(parsed.response, callerHadTools, caseRestore, findGlob, injected);
732
+ cloakResponsesObject(parsed.response, callerHadTools, caseRestore, findGlob, injected, argSchemas);
693
733
  return parsed.response as Record<string, unknown>;
694
734
  }
695
735
  } catch { }
@@ -700,7 +740,7 @@ export function sseToResponsesJson(
700
740
  try {
701
741
  const parsed = JSON.parse(ev.data);
702
742
  if (parsed?.response && typeof parsed.response === "object") {
703
- cloakResponsesObject(parsed.response, callerHadTools, caseRestore, findGlob, injected);
743
+ cloakResponsesObject(parsed.response, callerHadTools, caseRestore, findGlob, injected, argSchemas);
704
744
  return parsed.response as Record<string, unknown>;
705
745
  }
706
746
  } catch { }
@@ -709,7 +749,7 @@ export function sseToResponsesJson(
709
749
  try {
710
750
  const parsed = JSON.parse(sseText);
711
751
  if (parsed && typeof parsed === "object") {
712
- cloakResponsesObject(parsed, callerHadTools, caseRestore, findGlob, injected);
752
+ cloakResponsesObject(parsed, callerHadTools, caseRestore, findGlob, injected, argSchemas);
713
753
  return parsed;
714
754
  }
715
755
  } catch { }
@@ -726,8 +766,8 @@ export function sseToResponsesJson(
726
766
  * Only names this request injected are removed; caller blocks keep restored
727
767
  * names (caller casing, upstream glob back to caller find). Tool-less callers
728
768
  * never see tool_use: stray placeholder input folds to text, matching the
729
- * SSE aggregator fallback below. Block objects ride verbatim (only names
730
- * are ever rewritten).
769
+ * SSE aggregator fallback below. Ids ride verbatim; `input` is repaired
770
+ * against the caller's declared schema when the emitted shape cannot be right.
731
771
  */
732
772
  function cloakMessagesContent(
733
773
  msg: unknown,
@@ -735,6 +775,7 @@ function cloakMessagesContent(
735
775
  caseRestore?: CaseRestoreMap,
736
776
  findGlob?: FindGlobRestore,
737
777
  injected?: readonly string[],
778
+ argSchemas?: ReadonlyMap<string, Record<string, unknown>>,
738
779
  ): void {
739
780
  if (!msg || typeof msg !== "object" || Array.isArray(msg)) return;
740
781
  const content = (msg as Record<string, unknown>).content;
@@ -751,7 +792,11 @@ function cloakMessagesContent(
751
792
  const rec = block as Record<string, unknown>;
752
793
  const isInjected = typeof rec.name === "string" && injected !== undefined && injected.includes(rec.name.toLowerCase());
753
794
  if (callerHadTools && !isInjected) {
754
- if (typeof rec.name === "string") rec.name = restoreCallerName(rec.name, caseRestore, findGlob);
795
+ if (typeof rec.name === "string") {
796
+ const name = restoreCallerName(rec.name, caseRestore, findGlob);
797
+ rec.name = name;
798
+ rec.input = repairToolArgumentsValue(name, rec.input, argSchemas);
799
+ }
755
800
  kept.push(block);
756
801
  } else if (!callerHadTools) {
757
802
  let text = "";
@@ -787,6 +832,7 @@ export function sseToMessagesJson(
787
832
  caseRestore?: CaseRestoreMap,
788
833
  findGlob?: FindGlobRestore,
789
834
  injected?: readonly string[],
835
+ argSchemas?: ReadonlyMap<string, Record<string, unknown>>,
790
836
  ): Record<string, unknown> {
791
837
  const events = parseSseEvents(sseText);
792
838
  let id = "msg_freeflow";
@@ -848,7 +894,7 @@ export function sseToMessagesJson(
848
894
  continue;
849
895
  }
850
896
  if (type === "message" && parsed.role !== undefined) {
851
- cloakMessagesContent(parsed, callerHadTools, caseRestore, findGlob, injected);
897
+ cloakMessagesContent(parsed, callerHadTools, caseRestore, findGlob, injected, argSchemas);
852
898
  return parsed;
853
899
  }
854
900
  }
@@ -865,7 +911,8 @@ export function sseToMessagesJson(
865
911
  } catch {
866
912
  input = {};
867
913
  }
868
- content.push({ type: "tool_use", id: tool.id, name: restoreCallerName(tool.name, caseRestore, findGlob), input });
914
+ const name = restoreCallerName(tool.name, caseRestore, findGlob);
915
+ content.push({ type: "tool_use", id: tool.id, name, input: repairToolArgumentsValue(name, input, argSchemas) });
869
916
  }
870
917
  continue;
871
918
  }
@@ -900,6 +947,7 @@ export function convertSseToJson(
900
947
  caseRestore?: CaseRestoreMap,
901
948
  findGlob?: FindGlobRestore,
902
949
  injected?: readonly string[],
950
+ argSchemas?: ReadonlyMap<string, Record<string, unknown>>,
903
951
  ): string {
904
952
  if (!sseText || typeof sseText !== "string") return sseText;
905
953
  const trimmed = sseText.trim();
@@ -908,14 +956,13 @@ export function convertSseToJson(
908
956
  }
909
957
  try {
910
958
  if (pathname.endsWith("/responses")) {
911
- return JSON.stringify(sseToResponsesJson(trimmed, callerHadTools, caseRestore, findGlob, injected));
959
+ return JSON.stringify(sseToResponsesJson(trimmed, callerHadTools, caseRestore, findGlob, injected, argSchemas));
912
960
  }
913
961
  if (pathname.endsWith("/messages")) {
914
- return JSON.stringify(sseToMessagesJson(trimmed, callerHadTools, caseRestore, findGlob, injected));
962
+ return JSON.stringify(sseToMessagesJson(trimmed, callerHadTools, caseRestore, findGlob, injected, argSchemas));
915
963
  }
916
- return JSON.stringify(sseToChatCompletionJson(trimmed, callerHadTools, caseRestore, findGlob, injected));
964
+ return JSON.stringify(sseToChatCompletionJson(trimmed, callerHadTools, caseRestore, findGlob, injected, argSchemas));
917
965
  } catch {
918
966
  return sseText;
919
967
  }
920
- return sseText;
921
968
  }
package/src/proxy.ts CHANGED
@@ -51,6 +51,7 @@ import {
51
51
  clineChatBodyFromResponsesBody,
52
52
  translateToolsForPath,
53
53
  } from "./tool-translation.ts";
54
+ import { buildArgRepairSchemas } from "./tool-args.ts";
54
55
  // normalize removed — host pi-ai already normalizes thinking/reasoning before proxy
55
56
  import { relayFetch } from "./relay.ts";
56
57
  import { getActiveRelayState, markRelayFailure, orderedRelayCandidates } from "./relay-state.ts";
@@ -266,6 +267,9 @@ async function handleClineRequest(opts: {
266
267
  if (Array.isArray(chatBody.tools)) chatBody.tools = translateToolsForPath(chatBody.tools, "/v1/chat/completions");
267
268
  delete chatBody.prompt_cache_key;
268
269
  }
270
+ // Cline bypasses the fingerprint, but its arguments still malform the same
271
+ // way: capture the caller's own schemas before translation for repair.
272
+ const argSchemas = buildArgRepairSchemas(Array.isArray(parsedBody.tools) ? parsedBody.tools : []);
269
273
  chatBody.stream = true;
270
274
  let upstreamRes: Response;
271
275
  let limitHint: ClineLimitHint | undefined;
@@ -329,6 +333,9 @@ async function handleClineRequest(opts: {
329
333
  req,
330
334
  reqId,
331
335
  "direct",
336
+ argSchemas.size > 0
337
+ ? { callerHadTools: true, argSchemas, pathname: "/v1/chat/completions" }
338
+ : undefined,
332
339
  );
333
340
  return;
334
341
  }
@@ -344,7 +351,7 @@ async function handleClineRequest(opts: {
344
351
  return;
345
352
  }
346
353
  if (!responsesRequest) {
347
- const data = convertSseToJson(sseText, pathname);
354
+ const data = convertSseToJson(sseText, pathname, true, undefined, undefined, undefined, argSchemas);
348
355
  if (!res.headersSent) {
349
356
  res.writeHead(upstreamRes.status, { "content-type": "application/json" });
350
357
  res.end(data);
@@ -353,7 +360,7 @@ async function handleClineRequest(opts: {
353
360
  }
354
361
  return;
355
362
  }
356
- const chat = sseToChatCompletionJson(sseText);
363
+ const chat = sseToChatCompletionJson(sseText, true, undefined, undefined, undefined, argSchemas);
357
364
  const resp = chatResponsesJsonFromChatCompletion(chat, model);
358
365
  if (clientRequestedStream) {
359
366
  if (!res.headersSent) {
@@ -1046,20 +1053,22 @@ export function startProxy(
1046
1053
  let callerHadTools = true;
1047
1054
  let callerCaseRestore: CaseRestoreMap | undefined;
1048
1055
  let callerFindGlob: FindGlobRestore | undefined;
1049
- let callerInjected: string[] | undefined;
1056
+ let callerInjected: string[] | undefined;
1057
+ let callerArgSchemas: Map<string, Record<string, unknown>> | undefined;
1050
1058
  if (!isKilo && !isCline && parsedBody) {
1051
1059
  const fp = enforceOpencodeFingerprint(parsedBody, target.pathname);
1052
1060
  callerHadTools = fp.callerHadTools;
1053
1061
  callerCaseRestore = fp.caseRestore;
1054
1062
  callerFindGlob = fp.findGlob;
1055
1063
  callerInjected = fp.injected;
1064
+ callerArgSchemas = fp.argSchemas;
1056
1065
  bodyModified = true;
1057
1066
  }
1058
1067
  // Streaming cloak: thread this request's placeholder records into the SSE
1059
1068
  // pipe so streamed deltas get the same strip+restore as aggregated bodies.
1060
1069
  // Kilo/Cline bypass the fingerprint, so their streams stay verbatim.
1061
1070
  const streamCloak: StreamCloakOptions | undefined = !isKilo && !isCline && callerInjected !== undefined
1062
- ? { callerHadTools, caseRestore: callerCaseRestore, findGlob: callerFindGlob, injected: callerInjected, pathname: target.pathname }
1071
+ ? { callerHadTools, caseRestore: callerCaseRestore, findGlob: callerFindGlob, injected: callerInjected, argSchemas: callerArgSchemas, pathname: target.pathname }
1063
1072
  : undefined;
1064
1073
 
1065
1074
  const isStream = clientRequestedStream;
@@ -1091,6 +1100,9 @@ export function startProxy(
1091
1100
  // the timeout ceiling; the stream phase is owned by
1092
1101
  // pipeUpstreamStream and its close handling.
1093
1102
  const kiloController = new AbortController();
1103
+ // Kilo bodies ride verbatim, but its models malform arguments the same
1104
+ // way: capture the caller's schemas so the reply can be repaired too.
1105
+ const kiloArgSchemas = buildArgRepairSchemas(Array.isArray(parsedBody.tools) ? parsedBody.tools : []);
1094
1106
  const kiloTimeoutId = setTimeout(
1095
1107
  () => kiloController.abort(upstreamTimeoutError()),
1096
1108
  UPSTREAM_HEADER_TIMEOUT_MS,
@@ -1177,9 +1189,9 @@ export function startProxy(
1177
1189
  connection: "keep-alive",
1178
1190
  "x-accel-buffering": "no",
1179
1191
  });
1180
- // Kilo is fetched directly (not via the relay pool), so pass undefined:
1181
- // attributing kilo-side stream failures to an unrelated opencode relay
1182
- // would mark a healthy relay as failed.
1192
+ // Kilo is fetched directly (not via the relay pool), so pass undefined as
1193
+ // the issuer: attributing kilo-side stream failures to an unrelated
1194
+ // opencode relay would mark a healthy relay as failed.
1183
1195
  pipeUpstreamStream(
1184
1196
  Readable.fromWeb(
1185
1197
  response.body as unknown as WebReadableStream,
@@ -1188,11 +1200,21 @@ export function startProxy(
1188
1200
  req,
1189
1201
  reqId,
1190
1202
  undefined,
1203
+ kiloArgSchemas.size > 0
1204
+ ? {
1205
+ callerHadTools: true,
1206
+ argSchemas: kiloArgSchemas,
1207
+ pathname: target.pathname.endsWith("/responses") ? "/v1/responses" : "/v1/chat/completions",
1208
+ }
1209
+ : undefined,
1191
1210
  );
1192
1211
  } else {
1193
- const data = withRateLimitHint(response.status, await response.text());
1194
- const ct =
1195
- response.headers.get("content-type") || "application/json";
1212
+ let rawText = await response.text();
1213
+ if (response.ok && kiloArgSchemas.size > 0) {
1214
+ rawText = convertSseToJson(rawText, target.pathname, true, undefined, undefined, undefined, kiloArgSchemas);
1215
+ }
1216
+ const data = withRateLimitHint(response.status, rawText);
1217
+ const ct = response.headers.get("content-type") || "application/json";
1196
1218
  res.writeHead(response.status, { "content-type": ct });
1197
1219
  res.end(data);
1198
1220
  }
@@ -1458,7 +1480,7 @@ export function startProxy(
1458
1480
  response.headers.get("content-type") ||
1459
1481
  "application/json";
1460
1482
  if (response.ok && (ct.includes("text/event-stream") || rawText.includes("data:"))) {
1461
- rawText = convertSseToJson(rawText, target.pathname, callerHadTools, callerCaseRestore, callerFindGlob, callerInjected);
1483
+ rawText = convertSseToJson(rawText, target.pathname, callerHadTools, callerCaseRestore, callerFindGlob, callerInjected, callerArgSchemas);
1462
1484
  ct = "application/json";
1463
1485
  }
1464
1486
  const data = isRelayDisabledError(response.status, rawText)
@@ -1647,7 +1669,7 @@ export function startProxy(
1647
1669
  } else if (upstreamRes.body) {
1648
1670
  let rawText = await upstreamRes.text();
1649
1671
  if (upstreamRes.ok && (outHeaders["content-type"]?.includes("text/event-stream") || rawText.includes("data:"))) {
1650
- rawText = convertSseToJson(rawText, target.pathname, callerHadTools, callerCaseRestore, callerFindGlob, callerInjected);
1672
+ rawText = convertSseToJson(rawText, target.pathname, callerHadTools, callerCaseRestore, callerFindGlob, callerInjected, callerArgSchemas);
1651
1673
  outHeaders["content-type"] = "application/json";
1652
1674
  }
1653
1675
  res.writeHead(upstreamRes.status, outHeaders);
@@ -17,6 +17,7 @@ import {
17
17
  } from "./config.ts";
18
18
  import type { CaseRestoreMap, FindGlobRestore } from "./opencode-fingerprint.ts";
19
19
  import { restoreToolNameForCaller } from "./tool-translation.ts";
20
+ import { repairToolArguments } from "./tool-args.ts";
20
21
 
21
22
  /**
22
23
  * A premature stream end counts as "substantial" only when BOTH thresholds
@@ -153,8 +154,9 @@ export function _resetSseStatsForTest(): void {
153
154
  * on the streamed path, so this section mirrors the aggregate semantics
154
155
  * event-by-event: function_call deltas whose name is in this request's
155
156
  * injected list are dropped, surviving names get caller casing (Bash) and the
156
- * Pi find->glob rename restored. State is per-stream (no globals); the
157
- * non-stream path is untouched.
157
+ * Pi find->glob rename restored. Completed tool arguments are additionally
158
+ * repaired against `argSchemas` on the terminal events that carry the whole
159
+ * payload. State is per-stream (no globals); the non-stream path is untouched.
158
160
  */
159
161
  export interface StreamCloakOptions {
160
162
  /** False when the caller declared no tools (chat folds dropped args to text). */
@@ -167,6 +169,8 @@ export interface StreamCloakOptions {
167
169
  injected?: readonly string[];
168
170
  /** Request pathname; decides the Responses vs Chat event shapes. */
169
171
  pathname: string;
172
+ /** Caller tool schemas for argument repair; undefined/empty means byte-identical. */
173
+ argSchemas?: ReadonlyMap<string, Record<string, unknown>>;
170
174
  }
171
175
 
172
176
  /** Per-stream drop tracking (delta events carry no name, only an index/id). */
@@ -175,6 +179,22 @@ export interface SseStreamCloakState {
175
179
  responsesDroppedByItemId: Map<string, boolean>;
176
180
  chatDroppedByIndex: Map<number, boolean>;
177
181
  messagesDroppedByIndex: Map<number, boolean>;
182
+ /** Output index -> tool name, so nameless argument deltas can find their schema. */
183
+ responsesNameByIndex: Map<number, string>;
184
+ /** Anthropic block index -> tool name, same purpose for input_json deltas. */
185
+ messagesNameByIndex: Map<number, string>;
186
+ /** Chat tool-call index -> restored tool name. */
187
+ chatNameByIndex: Map<number, string>;
188
+ /**
189
+ * Argument fragments for tool calls whose schema admits a repair, keyed the
190
+ * same way as the name maps above. Streaming APIs deliver arguments in
191
+ * fragments, so a malformed packet can only be judged once complete: those
192
+ * fragments are held here and re-emitted, repaired, on the call's terminal
193
+ * event. Tools absent from `argSchemas` are never buffered, so their
194
+ * arguments still stream incrementally exactly as before.
195
+ */
196
+ chatArgsByIndex: Map<number, string>;
197
+ messagesArgsByIndex: Map<number, string>;
178
198
  }
179
199
 
180
200
  export function createSseStreamCloakState(): SseStreamCloakState {
@@ -183,6 +203,11 @@ export function createSseStreamCloakState(): SseStreamCloakState {
183
203
  responsesDroppedByItemId: new Map(),
184
204
  chatDroppedByIndex: new Map(),
185
205
  messagesDroppedByIndex: new Map(),
206
+ responsesNameByIndex: new Map(),
207
+ messagesNameByIndex: new Map(),
208
+ chatNameByIndex: new Map(),
209
+ chatArgsByIndex: new Map(),
210
+ messagesArgsByIndex: new Map(),
186
211
  };
187
212
  }
188
213
 
@@ -200,6 +225,8 @@ export function shouldBypassStreamCloak(cloak?: StreamCloakOptions): boolean {
200
225
  // Tool-less callers must still see tool calls stripped when the
201
226
  // injected record exists (legacy direct calls keep everything).
202
227
  if (!cloak.callerHadTools && cloak.injected !== undefined) return false;
228
+ // A repairable schema means fragmented arguments must be buffered.
229
+ if (cloak.argSchemas !== undefined && cloak.argSchemas.size > 0) return false;
203
230
  return true;
204
231
  }
205
232
 
@@ -207,6 +234,14 @@ function isInjectedName(name: unknown, injected?: readonly string[]): boolean {
207
234
  return typeof name === "string" && injected !== undefined && injected.includes(name.toLowerCase());
208
235
  }
209
236
 
237
+ /** True when the request declared a schema for this tool, so its arguments can be repaired. */
238
+ function isRepairableTool(
239
+ name: string,
240
+ schemas: ReadonlyMap<string, Record<string, unknown>> | undefined,
241
+ ): boolean {
242
+ return schemas !== undefined && schemas.size > 0 && schemas.has(name.trim().toLowerCase());
243
+ }
244
+
210
245
  /** Caller casing first, then the Pi find->glob rename (upstream glob->find). */
211
246
  function restoreStreamName(name: string, cloak: StreamCloakOptions): string {
212
247
  const cased = cloak.caseRestore?.[name.toLowerCase()] ?? name;
@@ -261,8 +296,9 @@ function rewriteResponsesBlock(
261
296
  const hadEventPrefix = /^\s*event:/m.test(block);
262
297
 
263
298
  // New function_call item announced: drop when injected; tool-less callers
264
- // drop every function_call, never leaking calls downstream. Ids and
265
- // arguments ride verbatim (only the name is ever rewritten).
299
+ // drop every function_call, never leaking calls downstream. Ids ride
300
+ // verbatim; `output_item.done` carries the whole argument string, so that is
301
+ // where schema repair applies (name-only deltas cannot be repaired yet).
266
302
  if (type === "response.output_item.added" || type === "response.output_item.done") {
267
303
  const item = parsed.item;
268
304
  if (item && typeof item === "object" && !Array.isArray(item)) {
@@ -275,6 +311,12 @@ function rewriteResponsesBlock(
275
311
  return null;
276
312
  }
277
313
  const restored = restoreStreamName(rec.name, cloak);
314
+ if (typeof parsed.output_index === "number") state.responsesNameByIndex.set(parsed.output_index, restored);
315
+ if (type === "response.output_item.done") {
316
+ rec.name = restored;
317
+ rec.arguments = repairToolArguments(restored, typeof rec.arguments === "string" ? rec.arguments : "", cloak.argSchemas);
318
+ return emitSseBlock(event, JSON.stringify(parsed), hadEventPrefix);
319
+ }
278
320
  if (restored !== rec.name) {
279
321
  rec.name = restored;
280
322
  return emitSseBlock(event, JSON.stringify(parsed), hadEventPrefix);
@@ -284,13 +326,24 @@ function rewriteResponsesBlock(
284
326
  return block;
285
327
  }
286
328
 
287
- // Argument deltas carry no name: earlier added/done decisions rule by index/id.
288
- // Tool-less callers drop every function_call_arguments delta outright.
329
+ // Argument deltas carry no name: earlier added/done decisions rule by index/id.
330
+ // Tool-less callers drop every function_call_arguments delta outright. The
331
+ // `.done` event carries the whole `arguments` string, so it is repairable.
289
332
  if (type === "response.function_call_arguments.delta" || type === "response.function_call_arguments.done") {
290
333
  if (!cloak.callerHadTools) return null;
291
334
  const droppedByIndex = typeof parsed.output_index === "number" && state.responsesDroppedByIndex.get(parsed.output_index) === true;
292
335
  const droppedById = typeof parsed.item_id === "string" && state.responsesDroppedByItemId.get(parsed.item_id) === true;
293
336
  if (droppedByIndex || droppedById) return null;
337
+ if (type === "response.function_call_arguments.done" && typeof parsed.arguments === "string") {
338
+ const name = typeof parsed.output_index === "number" ? state.responsesNameByIndex.get(parsed.output_index) : undefined;
339
+ if (name !== undefined) {
340
+ const repairedArgs = repairToolArguments(name, parsed.arguments, cloak.argSchemas);
341
+ if (repairedArgs !== parsed.arguments) {
342
+ parsed.arguments = repairedArgs;
343
+ return emitSseBlock(event, JSON.stringify(parsed), hadEventPrefix);
344
+ }
345
+ }
346
+ }
294
347
  return block;
295
348
  }
296
349
 
@@ -374,6 +427,28 @@ function rewriteChatBlock(
374
427
  continue;
375
428
  }
376
429
  const d = delta as Record<string, unknown>;
430
+ // Terminal chunk for this choice: re-emit every buffered tool call's whole
431
+ // argument packet, repaired. The fragments never went out, so this is the
432
+ // first and only time the client sees them. Runs before the tool_calls
433
+ // branch because the terminal chunk usually carries an empty delta.
434
+ // Indices flushed on this block: their arguments are final, so the
435
+ // per-delta loop below must not buffer them a second time.
436
+ const flushedIndices = new Set<number>();
437
+ if (c.finish_reason && state.chatArgsByIndex.size > 0) {
438
+ const flushed: unknown[] = [];
439
+ for (const [idx, args] of state.chatArgsByIndex) {
440
+ const name = state.chatNameByIndex.get(idx);
441
+ if (name === undefined) continue;
442
+ flushedIndices.add(idx);
443
+ flushed.push({ index: idx, type: "function", function: { name, arguments: repairToolArguments(name, args, cloak.argSchemas) } });
444
+ }
445
+ state.chatArgsByIndex.clear();
446
+ state.chatNameByIndex.clear();
447
+ if (flushed.length > 0) {
448
+ d.tool_calls = [...(Array.isArray(d.tool_calls) ? d.tool_calls : []), ...flushed];
449
+ changed = true;
450
+ }
451
+ }
377
452
  if (!Array.isArray(d.tool_calls)) {
378
453
  if (Object.keys(d).length > 0) allDeltasEmpty = false;
379
454
  continue;
@@ -407,6 +482,19 @@ function rewriteChatBlock(
407
482
  changed = true;
408
483
  }
409
484
  state.chatDroppedByIndex.delete(idx);
485
+ state.chatNameByIndex.set(idx, restored);
486
+ if (isRepairableTool(restored, cloak.argSchemas) && !flushedIndices.has(idx)) {
487
+ // Hold this call's arguments: the packet is only judgeable once whole,
488
+ // so it is re-emitted repaired on the terminal finish_reason chunk.
489
+ state.chatArgsByIndex.set(idx, "");
490
+ if (fnRec && typeof fnRec.arguments === "string" && fnRec.arguments !== "") {
491
+ state.chatArgsByIndex.set(idx, fnRec.arguments);
492
+ delete fnRec.arguments;
493
+ changed = true;
494
+ }
495
+ kept.push(tc);
496
+ continue;
497
+ }
410
498
  kept.push(tc);
411
499
  continue;
412
500
  }
@@ -415,8 +503,14 @@ function rewriteChatBlock(
415
503
  changed = true;
416
504
  continue;
417
505
  }
418
- kept.push(tc);
506
+ if (state.chatArgsByIndex.has(idx) && fnRec && typeof fnRec.arguments === "string") {
507
+ state.chatArgsByIndex.set(idx, (state.chatArgsByIndex.get(idx) ?? "") + fnRec.arguments);
508
+ delete fnRec.arguments;
509
+ changed = true;
510
+ continue;
419
511
  }
512
+ kept.push(tc);
513
+ }
420
514
  if (kept.length > 0) {
421
515
  d.tool_calls = kept;
422
516
  allDeltasEmpty = false;
@@ -522,17 +616,25 @@ function rewriteMessagesBlock(
522
616
  }
523
617
  if (typeof rec.name === "string") {
524
618
  const restored = restoreStreamName(rec.name, cloak);
525
- if (restored !== rec.name) {
526
- rec.name = restored;
527
- return emitSseBlock(event, JSON.stringify(parsed), hadEventPrefix);
619
+ rec.name = restored;
620
+ state.messagesNameByIndex.set(index, restored);
621
+ if (isRepairableTool(restored, cloak.argSchemas)) {
622
+ state.messagesArgsByIndex.set(index, typeof rec.input === "string" ? rec.input : "");
528
623
  }
624
+ return emitSseBlock(event, JSON.stringify(parsed), hadEventPrefix);
529
625
  }
530
626
  }
531
627
  return block;
532
628
  }
533
- // Input deltas carry no name: the start verdict rules by index.
629
+ // Input deltas carry no name: the start verdict rules by index. Repairable
630
+ // tools buffer their fragments, re-emitted whole on content_block_stop.
534
631
  if (type === "content_block_delta") {
535
632
  if (state.messagesDroppedByIndex.get(index) === true) return null;
633
+ const delta = parsed.delta as Record<string, unknown> | undefined;
634
+ if (state.messagesArgsByIndex.has(index) && delta && typeof delta.partial_json === "string") {
635
+ state.messagesArgsByIndex.set(index, (state.messagesArgsByIndex.get(index) ?? "") + delta.partial_json);
636
+ return null;
637
+ }
536
638
  return block;
537
639
  }
538
640
  // Block close for a dropped tool_use carries no name: drop it too.
@@ -541,7 +643,18 @@ function rewriteMessagesBlock(
541
643
  state.messagesDroppedByIndex.delete(index);
542
644
  return null;
543
645
  }
544
- return block;
646
+ const buffered = state.messagesArgsByIndex.get(index);
647
+ if (buffered === undefined) return block;
648
+ const name = state.messagesNameByIndex.get(index) ?? "";
649
+ state.messagesArgsByIndex.delete(index);
650
+ state.messagesNameByIndex.delete(index);
651
+ const flushed: Record<string, unknown> = {
652
+ type: "content_block_delta",
653
+ index,
654
+ delta: { type: "input_json_delta", partial_json: repairToolArguments(name, buffered, cloak.argSchemas) },
655
+ };
656
+ const stopBlock = emitSseBlock(event, JSON.stringify(parsed), hadEventPrefix);
657
+ return `${emitSseBlock("content_block_delta", JSON.stringify(flushed), false)}\n\n${stopBlock}`;
545
658
  }
546
659
  return block;
547
660
  }
@@ -0,0 +1,406 @@
1
+ /**
2
+ * Schema-driven repair for model-emitted tool-call arguments.
3
+ *
4
+ * Free models reliably lose tool-argument fidelity once a request carries a
5
+ * full agent tool inventory. Probed live 2026-10-08 against OpenCode Zen with
6
+ * the real OMP `todo` schema: at one tool the same model emits the canonical
7
+ * `list: [{ phase, items }]`, but at ~70 tools it delivered structured phases
8
+ * JSON-encoded into the flat string list, with each inner array wrapped as
9
+ * `items: { item: [...] }`. Sibling free models invented whole vocabularies
10
+ * (`phases` / `name` / `tasks`) the schema never declared.
11
+ *
12
+ * Two families are mechanically decidable from the CALLER's own declared
13
+ * schema, and this module repairs exactly those:
14
+ *
15
+ * - Envelope unwrap: a declared array that arrives wrapped in a single-key
16
+ * object (`items: { item: [...] }`) is unwrapped.
17
+ * - JSON-string decode: a declared object/array that arrives as a JSON string
18
+ * is parsed. Declared `string` properties are never decoded.
19
+ * - Structured promotion: a declared object-array property left ABSENT, with a
20
+ * declared string-array property whose every element parses to an object that
21
+ * validates against the object-array's item schema, is promoted across.
22
+ * Every element must validate, so one JSON-looking task label never moves.
23
+ *
24
+ * Invented vocabulary is deliberately NOT repaired: renaming `phases` to `list`
25
+ * is a naming coincidence, not a schema fact, and a generic proxy that guesses
26
+ * it would corrupt the next tool. Those calls ride verbatim and surface the
27
+ * host's own schema error. This mirrors the reference upstream, which also
28
+ * declines to fake a legal-looking argument packet
29
+ * (channel-pack/src/sse.ts normalizeToolArguments / isTruncatedArguments).
30
+ *
31
+ * Arguments change only when the result matches the declared schema, so
32
+ * well-formed calls come back byte-identical and truncated JSON is never
33
+ * completed.
34
+ */
35
+
36
+ import { canonicalizeTool } from "./tool-translation.ts";
37
+
38
+ /** Bound on recursive repair/validation; real host schemas nest 3-4 deep. */
39
+ const MAX_DEPTH = 8;
40
+
41
+ /** Declared JSON Schema type, tolerating union types like ["array","null"]. */
42
+ function schemaType(schema: Record<string, unknown>): string {
43
+ const declared = schema.type;
44
+ if (typeof declared === "string") return declared;
45
+ if (Array.isArray(declared)) {
46
+ for (const entry of declared) if (entry === "object" || entry === "array") return entry;
47
+ if (typeof declared[0] === "string") return declared[0];
48
+ }
49
+ return "";
50
+ }
51
+
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";
56
+ }
57
+
58
+ /**
59
+ * Does a value already satisfy the declared schema? Type, then required keys,
60
+ * then each present property recursively. A present property the schema does
61
+ * not declare is ignored (never fails): hosts decide their own extras policy,
62
+ * and this only gates whether a promotion is safe.
63
+ */
64
+ 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;
87
+ }
88
+ return true;
89
+ }
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);
94
+ return true;
95
+ }
96
+
97
+ /**
98
+ * Repair one value against its declared schema: decode a JSON-string object or
99
+ * array, unwrap a single-key envelope around an array, then recurse into array
100
+ * items and object properties. Returns the input untouched when nothing
101
+ * applies, so callers can gate re-serialization on `changed`.
102
+ */
103
+ function repairValue(
104
+ value: unknown,
105
+ schema: Record<string, unknown>,
106
+ depth: number,
107
+ ): { value: unknown; changed: boolean } {
108
+ if (depth > MAX_DEPTH) return { value, changed: false };
109
+ const type = schemaType(schema);
110
+ if (type !== "array" && type !== "object") return { value, changed: false };
111
+
112
+ // Declared object/array that arrived as a JSON string: decode and retry.
113
+ if (typeof value === "string") {
114
+ const trimmed = value.trim();
115
+ if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
116
+ try {
117
+ const parsed: unknown = JSON.parse(trimmed);
118
+ if (parsed !== null && typeof parsed === "object") {
119
+ const inner = repairValue(parsed, schema, depth + 1);
120
+ return { value: inner.value, changed: true };
121
+ }
122
+ } catch {
123
+ // Truncated or non-JSON prose: never complete it.
124
+ }
125
+ }
126
+ return { value, changed: false };
127
+ }
128
+
129
+ if (type === "array") {
130
+ // Single-key envelope around the real array: { item: [...] } -> [...]
131
+ let candidate = value;
132
+ if (value !== null && typeof value === "object" && !Array.isArray(value)) {
133
+ const entries = Object.entries(value as Record<string, unknown>);
134
+ if (entries.length === 1 && Array.isArray(entries[0][1])) candidate = entries[0][1];
135
+ }
136
+ if (candidate !== value) {
137
+ const inner = repairValue(candidate, schema, depth + 1);
138
+ return { value: inner.value, changed: true };
139
+ }
140
+ if (!Array.isArray(value)) return { value, changed: false };
141
+ const items = schema.items;
142
+ if (items === null || typeof items !== "object" || Array.isArray(items)) return { value, changed: false };
143
+ let changed = false;
144
+ const out = value.map((element) => {
145
+ const repaired = repairValue(element, items as Record<string, unknown>, depth + 1);
146
+ if (repaired.changed) changed = true;
147
+ return repaired.value;
148
+ });
149
+ return { value: changed ? out : value, changed };
150
+ }
151
+
152
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
153
+ return { value, changed: false };
154
+ }
155
+ const props = schema.properties;
156
+ if (props === null || typeof props !== "object" || Array.isArray(props)) {
157
+ return { value, changed: false };
158
+ }
159
+ let changed = false;
160
+ const out: Record<string, unknown> = {};
161
+ for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
162
+ const propSchema = (props as Record<string, unknown>)[key];
163
+ if (propSchema === null || typeof propSchema !== "object" || Array.isArray(propSchema)) {
164
+ out[key] = entry;
165
+ continue;
166
+ }
167
+ const repaired = repairValue(entry, propSchema as Record<string, unknown>, depth + 1);
168
+ if (repaired.changed) changed = true;
169
+ out[key] = repaired.value;
170
+ }
171
+ return { value: changed ? out : value, changed };
172
+ }
173
+
174
+ /**
175
+ * Move an emitted object's unknown keys onto the declared properties it left
176
+ * missing, but only when the mapping is FORCED: every unknown key must have
177
+ * exactly one structurally compatible missing property (the single missing
178
+ * string slot, the single missing array slot, ...). Nothing is matched by name
179
+ * similarity, and an ambiguous element returns null so the caller discards it.
180
+ *
181
+ * This is what recovers the free models' invented parallel vocabulary — a
182
+ * model that writes `{ name, tasks }` for a declared `{ phase, items }` is
183
+ * mapped by role, not by guessing that `name` means `phase`.
184
+ */
185
+ function adoptDeclaredKeys(
186
+ value: Record<string, unknown>,
187
+ schema: Record<string, unknown>,
188
+ ): Record<string, unknown> | null {
189
+ const props = schema.properties;
190
+ if (props === null || typeof props !== "object" || Array.isArray(props)) return null;
191
+ const declared = props as Record<string, Record<string, unknown>>;
192
+ const unknownKeys = Object.keys(value).filter((key) => !(key in declared));
193
+ // Nothing unknown: the element already states its intent, so leave it be.
194
+ if (unknownKeys.length === 0) return null;
195
+ const missing = Object.keys(declared).filter((key) => !(key in value));
196
+ if (unknownKeys.length > missing.length) return null;
197
+ const taken = new Set<string>();
198
+ const targets = new Map<string, string>();
199
+ for (const key of unknownKeys) {
200
+ const raw = value[key];
201
+ const want = Array.isArray(raw) ? "array" : raw !== null && typeof raw === "object" ? "object" : typeof raw;
202
+ const candidates = missing.filter((name) => !taken.has(name) && schemaType(declared[name]) === want);
203
+ if (candidates.length !== 1) return null;
204
+ taken.add(candidates[0]);
205
+ targets.set(key, candidates[0]);
206
+ }
207
+ const out: Record<string, unknown> = {};
208
+ for (const [key, entry] of Object.entries(value)) out[targets.get(key) ?? key] = entry;
209
+ return out;
210
+ }
211
+
212
+ /** True when every key on the object is declared by the schema. */
213
+ function elementKeysDeclared(value: Record<string, unknown>, schema: Record<string, unknown>): boolean {
214
+ const props = schema.properties;
215
+ if (props === null || typeof props !== "object" || Array.isArray(props)) return false;
216
+ return Object.keys(value).every((key) => key in (props as Record<string, unknown>));
217
+ }
218
+
219
+ /**
220
+ * Move a model's misplaced phases into the declared object-array property it
221
+ * left absent. Two source shapes are accepted, both reproduced live against
222
+ * the free models: a declared string-array whose elements are JSON-encoded
223
+ * phase objects, and an entirely undeclared property holding phase objects
224
+ * under invented names. Either way EVERY element must end up validating
225
+ * against the target item schema, so one JSON-looking task label or one
226
+ * ambiguous element discards the whole move and the call rides through
227
+ * untouched.
228
+ */
229
+ function promoteStringArray(
230
+ args: Record<string, unknown>,
231
+ schema: Record<string, unknown>,
232
+ depth: number,
233
+ ): Record<string, unknown> {
234
+ if (depth > MAX_DEPTH) return args;
235
+ const props = schema.properties;
236
+ if (props === null || typeof props !== "object" || Array.isArray(props)) return args;
237
+ const declared = props as Record<string, Record<string, unknown>>;
238
+ const required = new Set(
239
+ Array.isArray(schema.required) ? schema.required.filter((key): key is string => typeof key === "string") : [],
240
+ );
241
+ const out = { ...args };
242
+ let moved = false;
243
+ for (const [targetName, targetSchema] of Object.entries(declared)) {
244
+ if (schemaType(targetSchema) !== "array") continue;
245
+ const targetItems = targetSchema.items;
246
+ if (targetItems === null || typeof targetItems !== "object" || Array.isArray(targetItems)) continue;
247
+ if (schemaType(targetItems as Record<string, unknown>) !== "object") continue;
248
+ if (targetName in out) continue;
249
+ for (const [sourceName, sourceValue] of Object.entries(args)) {
250
+ if (sourceName === targetName) continue;
251
+ const sourceSchema = declared[sourceName];
252
+ // A DECLARED source must be a scalar-item array: that is the only way a
253
+ // string slot can legitimately hold an encoded structure. An UNDECLARED
254
+ // source is the invented-vocabulary shape and is accepted as emitted.
255
+ if (sourceSchema !== undefined) {
256
+ if (schemaType(sourceSchema) !== "array") continue;
257
+ const sourceItems = sourceSchema.items;
258
+ if (sourceItems !== null && typeof sourceItems === "object" && !Array.isArray(sourceItems) &&
259
+ schemaType(sourceItems as Record<string, unknown>) === "object") continue;
260
+ }
261
+ // A JSON-string source stands for the array it encodes.
262
+ let source: unknown = sourceValue;
263
+ if (typeof source === "string") {
264
+ const trimmed = source.trim();
265
+ if (!trimmed.startsWith("[")) continue;
266
+ try {
267
+ source = JSON.parse(trimmed);
268
+ } catch {
269
+ continue;
270
+ }
271
+ }
272
+ if (!Array.isArray(source) || source.length === 0) continue;
273
+ const promoted: unknown[] = [];
274
+ let usable = true;
275
+ for (const element of source) {
276
+ let parsed: unknown = element;
277
+ if (typeof parsed === "string") {
278
+ try {
279
+ parsed = JSON.parse(parsed);
280
+ } catch {
281
+ usable = false;
282
+ break;
283
+ }
284
+ }
285
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
286
+ usable = false;
287
+ break;
288
+ }
289
+ const item = parsed as Record<string, unknown>;
290
+ const repaired = repairValue(item, targetItems as Record<string, unknown>, depth + 1);
291
+ const shaped =
292
+ repaired.value !== null && typeof repaired.value === "object" && !Array.isArray(repaired.value)
293
+ ? (repaired.value as Record<string, unknown>)
294
+ : item;
295
+ const adopted = adoptDeclaredKeys(shaped, targetItems as Record<string, unknown>);
296
+ const candidate = adopted ?? shaped;
297
+ if (!elementKeysDeclared(candidate, targetItems as Record<string, unknown>)) {
298
+ // Keys the schema never declares survived and could not be placed:
299
+ // promoting would smuggle the model's invention into the host.
300
+ usable = false;
301
+ break;
302
+ }
303
+ if (!matchesSchema(candidate, targetItems as Record<string, unknown>, depth + 1)) {
304
+ usable = false;
305
+ break;
306
+ }
307
+ promoted.push(candidate);
308
+ }
309
+ if (!usable) continue;
310
+ out[targetName] = promoted;
311
+ moved = true;
312
+ if (!required.has(sourceName)) delete out[sourceName];
313
+ break;
314
+ }
315
+ }
316
+ return moved ? out : args;
317
+ }
318
+
319
+ /**
320
+ * 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.
324
+ *
325
+ * Keyed by CALLER name, which is what arrives downstream after the casing and
326
+ * find->glob restores. Injected fingerprint placeholders are absent by
327
+ * construction, so they are never repaired.
328
+ */
329
+ 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;
343
+ }
344
+
345
+ /**
346
+ * Repair one tool call's arguments against its declared schema. `name` is the
347
+ * downstream (restored) caller tool name. Returns the input string untouched
348
+ * unless the repair produced a schema-valid result, so well-formed calls stay
349
+ * byte-identical and truncated JSON is never completed.
350
+ */
351
+ export function repairToolArguments(
352
+ name: string,
353
+ argsJson: string,
354
+ schemas: ReadonlyMap<string, Record<string, unknown>> | undefined,
355
+ ): string {
356
+ if (schemas === undefined || schemas.size === 0) return argsJson;
357
+ const schema = schemas.get(name.trim().toLowerCase());
358
+ if (schema === undefined) return argsJson;
359
+ let parsed: unknown;
360
+ try {
361
+ parsed = JSON.parse(argsJson);
362
+ } catch {
363
+ // Truncated or non-JSON arguments: paper over nothing.
364
+ return argsJson;
365
+ }
366
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return argsJson;
367
+ const rec = parsed as Record<string, unknown>;
368
+ const repaired = repairValue(rec, schema, 0);
369
+ const value = repaired.value === null || typeof repaired.value !== "object" || Array.isArray(repaired.value)
370
+ ? rec
371
+ : (repaired.value as Record<string, unknown>);
372
+ const promoted = promoteStringArray(value, schema, 0);
373
+ const result = promoted === value ? value : promoted;
374
+ if (!repaired.changed && promoted === value) return argsJson;
375
+ if (!matchesSchema(result, schema, 0)) return argsJson;
376
+ return JSON.stringify(result);
377
+ }
378
+
379
+ /**
380
+ * Value-level repair for wire APIs that carry tool arguments already parsed
381
+ * (Anthropic `tool_use.input`). Returns the input object untouched unless the
382
+ * repair produced a schema-valid result, so a well-formed call keeps
383
+ * reference identity and callers can skip re-serialization.
384
+ */
385
+ export function repairToolArgumentsValue(
386
+ name: string,
387
+ args: unknown,
388
+ schemas: ReadonlyMap<string, Record<string, unknown>> | undefined,
389
+ ): unknown {
390
+ if (schemas === undefined || schemas.size === 0) return args;
391
+ const schema = schemas.get(name.trim().toLowerCase());
392
+ if (schema === undefined) return args;
393
+ if (args === null || typeof args !== "object" || Array.isArray(args)) return args;
394
+ const rec = args as Record<string, unknown>;
395
+ const repaired = repairValue(rec, schema, 0);
396
+ const value = repaired.value === null || typeof repaired.value !== "object" || Array.isArray(repaired.value)
397
+ ? rec
398
+ : (repaired.value as Record<string, unknown>);
399
+ // Promotion must run even when shape repair was idle: the reported payload
400
+ // is already correctly typed, it just carries the phases in the wrong slot.
401
+ const promoted = promoteStringArray(value, schema, 0);
402
+ const result = promoted === value ? value : promoted;
403
+ if (result === args) return args;
404
+ if (!matchesSchema(result, schema, 0)) return args;
405
+ return result;
406
+ }