@f5-sales-demo/pi-ai 21.10.0 → 21.11.2
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/package.json +2 -2
- package/src/utils/validation.ts +119 -161
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@f5-sales-demo/pi-ai",
|
|
4
|
-
"version": "21.
|
|
4
|
+
"version": "21.11.2",
|
|
5
5
|
"description": "Unified LLM API with automatic model discovery and provider configuration",
|
|
6
6
|
"homepage": "https://github.com/f5-sales-demo/xcsh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"@anthropic-ai/sdk": "^0.115",
|
|
47
47
|
"@aws-sdk/client-bedrock-runtime": "^3",
|
|
48
48
|
"@bufbuild/protobuf": "^2.11",
|
|
49
|
-
"@f5-sales-demo/pi-utils": "21.
|
|
49
|
+
"@f5-sales-demo/pi-utils": "21.11.2",
|
|
50
50
|
"@google/genai": "^2.15",
|
|
51
51
|
"@sinclair/typebox": "^0.34",
|
|
52
52
|
"@smithy/node-http-handler": "^4.4",
|
package/src/utils/validation.ts
CHANGED
|
@@ -90,155 +90,6 @@ function tryParseNumberString(value: string, expectedTypes: string[]): { value:
|
|
|
90
90
|
return { value: parsed, changed: true };
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
-
function tryParseLeadingJsonContainer(value: string): unknown | undefined {
|
|
94
|
-
const firstChar = value[0];
|
|
95
|
-
const closingChar = firstChar === "{" ? "}" : firstChar === "[" ? "]" : undefined;
|
|
96
|
-
if (!closingChar) return undefined;
|
|
97
|
-
|
|
98
|
-
let depth = 0;
|
|
99
|
-
let inString = false;
|
|
100
|
-
let escaped = false;
|
|
101
|
-
|
|
102
|
-
for (let index = 0; index < value.length; index += 1) {
|
|
103
|
-
const char = value[index];
|
|
104
|
-
|
|
105
|
-
if (inString) {
|
|
106
|
-
if (escaped) {
|
|
107
|
-
escaped = false;
|
|
108
|
-
continue;
|
|
109
|
-
}
|
|
110
|
-
if (char === "\\") {
|
|
111
|
-
escaped = true;
|
|
112
|
-
continue;
|
|
113
|
-
}
|
|
114
|
-
if (char === '"') inString = false;
|
|
115
|
-
continue;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
if (char === '"') {
|
|
119
|
-
inString = true;
|
|
120
|
-
continue;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
if (char === firstChar) {
|
|
124
|
-
depth += 1;
|
|
125
|
-
continue;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
if (char !== closingChar) continue;
|
|
129
|
-
depth -= 1;
|
|
130
|
-
if (depth !== 0) continue;
|
|
131
|
-
|
|
132
|
-
const prefix = value.slice(0, index + 1);
|
|
133
|
-
try {
|
|
134
|
-
return JSON.parse(prefix) as unknown;
|
|
135
|
-
} catch {
|
|
136
|
-
// LLMs sometimes emit literal `\n` or `\t` between JSON tokens
|
|
137
|
-
// (e.g. `[{...}\n]`). Convert these to real whitespace and retry.
|
|
138
|
-
const cleaned = cleanLiteralEscapes(prefix);
|
|
139
|
-
if (cleaned !== prefix) {
|
|
140
|
-
try {
|
|
141
|
-
return JSON.parse(cleaned) as unknown;
|
|
142
|
-
} catch {}
|
|
143
|
-
}
|
|
144
|
-
// Also try single-char healing on the extracted prefix.
|
|
145
|
-
return tryHealMalformedJson(prefix);
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
return undefined;
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
/**
|
|
153
|
-
* Replace literal `\n`, `\t`, `\r` sequences that appear OUTSIDE of JSON
|
|
154
|
-
* strings with actual whitespace. LLMs sometimes produce these when they
|
|
155
|
-
* confuse the tool-call encoding with the content encoding.
|
|
156
|
-
*/
|
|
157
|
-
function cleanLiteralEscapes(value: string): string {
|
|
158
|
-
let result = "";
|
|
159
|
-
let inString = false;
|
|
160
|
-
let i = 0;
|
|
161
|
-
while (i < value.length) {
|
|
162
|
-
const ch = value[i];
|
|
163
|
-
if (inString) {
|
|
164
|
-
if (ch === "\\" && i + 1 < value.length) {
|
|
165
|
-
result += ch + value[i + 1];
|
|
166
|
-
i += 2;
|
|
167
|
-
continue;
|
|
168
|
-
}
|
|
169
|
-
if (ch === '"') inString = false;
|
|
170
|
-
result += ch;
|
|
171
|
-
i += 1;
|
|
172
|
-
continue;
|
|
173
|
-
}
|
|
174
|
-
if (ch === '"') {
|
|
175
|
-
inString = true;
|
|
176
|
-
result += ch;
|
|
177
|
-
i += 1;
|
|
178
|
-
continue;
|
|
179
|
-
}
|
|
180
|
-
// Outside a string: replace literal \n, \t, \r with whitespace
|
|
181
|
-
if (ch === "\\" && i + 1 < value.length) {
|
|
182
|
-
const next = value[i + 1];
|
|
183
|
-
if (next === "n" || next === "t" || next === "r") {
|
|
184
|
-
result += " ";
|
|
185
|
-
i += 2;
|
|
186
|
-
continue;
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
result += ch;
|
|
190
|
-
i += 1;
|
|
191
|
-
}
|
|
192
|
-
return result;
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
/** Maximum single-character edits to attempt when healing malformed JSON. */
|
|
196
|
-
const MAX_HEAL_DISTANCE = 3;
|
|
197
|
-
const BRACKET_CHARS = ["[", "]", "{", "}"] as const;
|
|
198
|
-
|
|
199
|
-
/**
|
|
200
|
-
* Attempts to heal near-valid JSON by applying single-character edits near the
|
|
201
|
-
* end of the string. LLMs (especially smaller ones) sometimes produce JSON with
|
|
202
|
-
* a single misplaced, extra, or wrong bracket at the end — e.g. `"}]"` becomes
|
|
203
|
-
* `"]}"` or gets an extra `}` appended. This function tries:
|
|
204
|
-
* 1. Removing a single character from the last few positions
|
|
205
|
-
* 2. Replacing a single character in the last few positions with each bracket type
|
|
206
|
-
*
|
|
207
|
-
* Returns the parsed value on success, undefined on failure.
|
|
208
|
-
*/
|
|
209
|
-
function tryHealMalformedJson(value: string): unknown | undefined {
|
|
210
|
-
// Verify it actually fails to parse
|
|
211
|
-
try {
|
|
212
|
-
return JSON.parse(value) as unknown;
|
|
213
|
-
} catch {}
|
|
214
|
-
|
|
215
|
-
// Only attempt edits within the last few characters — the error is always
|
|
216
|
-
// a bracket issue at the tail for the class of LLM mistakes this targets.
|
|
217
|
-
const tailStart = Math.max(0, value.length - (MAX_HEAL_DISTANCE * 2 + 1));
|
|
218
|
-
|
|
219
|
-
// Strategy 1: remove a single character from the tail
|
|
220
|
-
for (let i = tailStart; i < value.length; i += 1) {
|
|
221
|
-
const candidate = value.slice(0, i) + value.slice(i + 1);
|
|
222
|
-
try {
|
|
223
|
-
return JSON.parse(candidate) as unknown;
|
|
224
|
-
} catch {}
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
// Strategy 2: replace a single character in the tail with each bracket type
|
|
228
|
-
for (let i = tailStart; i < value.length; i += 1) {
|
|
229
|
-
const original = value[i];
|
|
230
|
-
for (const replacement of BRACKET_CHARS) {
|
|
231
|
-
if (replacement === original) continue;
|
|
232
|
-
const candidate = value.slice(0, i) + replacement + value.slice(i + 1);
|
|
233
|
-
try {
|
|
234
|
-
return JSON.parse(candidate) as unknown;
|
|
235
|
-
} catch {}
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
return undefined;
|
|
240
|
-
}
|
|
241
|
-
|
|
242
93
|
/**
|
|
243
94
|
* Attempts to parse a string as JSON if it looks like a JSON literal and
|
|
244
95
|
* the parsed result matches one of the expected types.
|
|
@@ -285,18 +136,6 @@ function tryParseJsonForTypes(value: string, expectedTypes: string[]): { value:
|
|
|
285
136
|
return { value: parsed, changed: true };
|
|
286
137
|
}
|
|
287
138
|
} catch {
|
|
288
|
-
if (looksJsonObject || looksJsonArray) {
|
|
289
|
-
// Try extracting a valid JSON prefix (handles trailing junk after balanced container)
|
|
290
|
-
const leading = tryParseLeadingJsonContainer(trimmed);
|
|
291
|
-
if (leading !== undefined && matchesExpectedType(leading, expectedTypes)) {
|
|
292
|
-
return { value: leading, changed: true };
|
|
293
|
-
}
|
|
294
|
-
// Try healing single-character bracket errors near the end of the string
|
|
295
|
-
const healed = tryHealMalformedJson(trimmed);
|
|
296
|
-
if (healed !== undefined && matchesExpectedType(healed, expectedTypes)) {
|
|
297
|
-
return { value: healed, changed: true };
|
|
298
|
-
}
|
|
299
|
-
}
|
|
300
139
|
return { value, changed: false };
|
|
301
140
|
}
|
|
302
141
|
|
|
@@ -513,6 +352,115 @@ function normalizeOptionalNullsForSchema(schema: unknown, value: unknown): { val
|
|
|
513
352
|
return { value: changed ? nextValue : value, changed };
|
|
514
353
|
}
|
|
515
354
|
|
|
355
|
+
const PROTOTYPE_SENSITIVE_KEYS = new Set(["__proto__", "constructor", "prototype"]);
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Strictly decodes JSON strings at schema nodes that explicitly accept an
|
|
359
|
+
* object or array, then continues normalization inside the decoded value.
|
|
360
|
+
* This schema-first pass is not limited by the number or shape of AJV errors.
|
|
361
|
+
*/
|
|
362
|
+
function normalizeJsonContainersForSchema(schema: unknown, value: unknown): { value: unknown; changed: boolean } {
|
|
363
|
+
if (schema === null || typeof schema !== "object" || Array.isArray(schema)) {
|
|
364
|
+
return { value, changed: false };
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const schemaObject = schema as Record<string, unknown>;
|
|
368
|
+
for (const keyword of ["anyOf", "oneOf"] as const) {
|
|
369
|
+
const branches = schemaObject[keyword];
|
|
370
|
+
if (!Array.isArray(branches)) continue;
|
|
371
|
+
|
|
372
|
+
let changedCandidate: { value: unknown; changed: true } | undefined;
|
|
373
|
+
for (const branch of branches) {
|
|
374
|
+
const normalized = normalizeJsonContainersForSchema(branch, value);
|
|
375
|
+
if (!normalized.changed) continue;
|
|
376
|
+
changedCandidate ??= normalized as { value: unknown; changed: true };
|
|
377
|
+
try {
|
|
378
|
+
if (ajv.compile(branch)(normalized.value)) return normalized;
|
|
379
|
+
} catch {
|
|
380
|
+
// The full tool-schema compilation reports invalid schemas consistently.
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
if (changedCandidate) return changedCandidate;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
let normalizedValue = value;
|
|
387
|
+
let changed = false;
|
|
388
|
+
const declaredTypes = normalizeExpectedTypes(schemaObject.type);
|
|
389
|
+
const containerTypes = declaredTypes.filter(type => type === "array" || type === "object");
|
|
390
|
+
|
|
391
|
+
if (typeof normalizedValue === "string" && containerTypes.length > 0) {
|
|
392
|
+
const trimmed = normalizedValue.trim();
|
|
393
|
+
const hasMatchingDelimiter =
|
|
394
|
+
(containerTypes.includes("array") && trimmed.startsWith("[") && trimmed.endsWith("]")) ||
|
|
395
|
+
(containerTypes.includes("object") && trimmed.startsWith("{") && trimmed.endsWith("}"));
|
|
396
|
+
if (hasMatchingDelimiter) {
|
|
397
|
+
try {
|
|
398
|
+
const parsed = JSON.parse(trimmed) as unknown;
|
|
399
|
+
if (matchesExpectedType(parsed, containerTypes)) {
|
|
400
|
+
normalizedValue = parsed;
|
|
401
|
+
changed = true;
|
|
402
|
+
}
|
|
403
|
+
} catch {
|
|
404
|
+
return { value, changed: false };
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
if (Array.isArray(schemaObject.allOf)) {
|
|
410
|
+
for (const branch of schemaObject.allOf) {
|
|
411
|
+
const normalized = normalizeJsonContainersForSchema(branch, normalizedValue);
|
|
412
|
+
if (!normalized.changed) continue;
|
|
413
|
+
normalizedValue = normalized.value;
|
|
414
|
+
changed = true;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
if (Array.isArray(normalizedValue)) {
|
|
419
|
+
const itemSchema = schemaObject.items;
|
|
420
|
+
if (itemSchema !== null && typeof itemSchema === "object" && !Array.isArray(itemSchema)) {
|
|
421
|
+
let nextValue = normalizedValue;
|
|
422
|
+
for (let index = 0; index < normalizedValue.length; index += 1) {
|
|
423
|
+
const normalized = normalizeJsonContainersForSchema(itemSchema, normalizedValue[index]);
|
|
424
|
+
if (!normalized.changed) continue;
|
|
425
|
+
if (nextValue === normalizedValue) nextValue = [...normalizedValue];
|
|
426
|
+
nextValue[index] = normalized.value;
|
|
427
|
+
changed = true;
|
|
428
|
+
}
|
|
429
|
+
normalizedValue = nextValue;
|
|
430
|
+
}
|
|
431
|
+
return { value: changed ? normalizedValue : value, changed };
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
if (normalizedValue === null || typeof normalizedValue !== "object") {
|
|
435
|
+
return { value: changed ? normalizedValue : value, changed };
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const properties =
|
|
439
|
+
schemaObject.properties !== null &&
|
|
440
|
+
typeof schemaObject.properties === "object" &&
|
|
441
|
+
!Array.isArray(schemaObject.properties)
|
|
442
|
+
? (schemaObject.properties as Record<string, unknown>)
|
|
443
|
+
: undefined;
|
|
444
|
+
if (!properties) return { value: changed ? normalizedValue : value, changed };
|
|
445
|
+
|
|
446
|
+
let nextRecord = normalizedValue as Record<string, unknown>;
|
|
447
|
+
for (const [key, propertySchema] of Object.entries(properties)) {
|
|
448
|
+
if (PROTOTYPE_SENSITIVE_KEYS.has(key) || !Object.hasOwn(nextRecord, key)) continue;
|
|
449
|
+
const normalized = normalizeJsonContainersForSchema(propertySchema, nextRecord[key]);
|
|
450
|
+
if (!normalized.changed) continue;
|
|
451
|
+
if (nextRecord === normalizedValue) nextRecord = { ...nextRecord };
|
|
452
|
+
Object.defineProperty(nextRecord, key, {
|
|
453
|
+
configurable: true,
|
|
454
|
+
enumerable: true,
|
|
455
|
+
value: normalized.value,
|
|
456
|
+
writable: true,
|
|
457
|
+
});
|
|
458
|
+
changed = true;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
return { value: changed ? nextRecord : value, changed };
|
|
462
|
+
}
|
|
463
|
+
|
|
516
464
|
/**
|
|
517
465
|
* Attempts to fix type errors by parsing JSON-encoded strings.
|
|
518
466
|
*
|
|
@@ -620,6 +568,11 @@ export function validateToolArguments(tool: Tool, toolCall: ToolCall): ToolCall[
|
|
|
620
568
|
// validation would pass (e.g., optional string field where "null" is a valid string).
|
|
621
569
|
let normalizedArgs: unknown = originalArgs;
|
|
622
570
|
let changed = false;
|
|
571
|
+
const containerNormalization = normalizeJsonContainersForSchema(tool.parameters, normalizedArgs);
|
|
572
|
+
if (containerNormalization.changed) {
|
|
573
|
+
normalizedArgs = containerNormalization.value;
|
|
574
|
+
changed = true;
|
|
575
|
+
}
|
|
623
576
|
|
|
624
577
|
const initialNormalization = normalizeOptionalNullsForSchema(tool.parameters, normalizedArgs);
|
|
625
578
|
if (initialNormalization.changed) {
|
|
@@ -639,6 +592,11 @@ export function validateToolArguments(tool: Tool, toolCall: ToolCall): ToolCall[
|
|
|
639
592
|
normalizedArgs = coercion.value;
|
|
640
593
|
changed = true;
|
|
641
594
|
|
|
595
|
+
const containerNormalization = normalizeJsonContainersForSchema(tool.parameters, normalizedArgs);
|
|
596
|
+
if (containerNormalization.changed) {
|
|
597
|
+
normalizedArgs = containerNormalization.value;
|
|
598
|
+
}
|
|
599
|
+
|
|
642
600
|
const nullNormalization = normalizeOptionalNullsForSchema(tool.parameters, normalizedArgs);
|
|
643
601
|
if (nullNormalization.changed) {
|
|
644
602
|
normalizedArgs = nullNormalization.value;
|