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 +17 -0
- package/package.json +1 -1
- package/src/opencode-fingerprint.ts +77 -30
- package/src/proxy.ts +34 -12
- package/src/stream-pipe.ts +125 -12
- package/src/tool-args.ts +406 -0
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.
|
|
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
|
-
|
|
528
|
-
|
|
529
|
-
|
|
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
|
-
|
|
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
|
-
|
|
584
|
-
|
|
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(
|
|
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
|
-
|
|
610
|
-
|
|
611
|
-
|
|
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)
|
|
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
|
|
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(
|
|
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")
|
|
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.
|
|
730
|
-
*
|
|
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")
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
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);
|
package/src/stream-pipe.ts
CHANGED
|
@@ -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.
|
|
157
|
-
*
|
|
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
|
|
265
|
-
//
|
|
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
|
-
|
|
288
|
-
|
|
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
|
-
|
|
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
|
-
|
|
526
|
-
|
|
527
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/tool-args.ts
ADDED
|
@@ -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
|
+
}
|