@oh-my-pi/pi-ai 3.37.0 → 3.37.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-ai",
3
- "version": "3.37.0",
3
+ "version": "3.37.1",
4
4
  "description": "Unified LLM API with automatic model discovery and provider configuration",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -1,12 +1,257 @@
1
1
  import AjvModule from "ajv";
2
2
  import addFormatsModule from "ajv-formats";
3
3
 
4
- // Handle both default and named exports
4
+ // Handle both default and named exports (ESM/CJS interop)
5
5
  const Ajv = (AjvModule as any).default || AjvModule;
6
6
  const addFormats = (addFormatsModule as any).default || addFormatsModule;
7
7
 
8
8
  import type { Tool, ToolCall } from "../types";
9
9
 
10
+ // ============================================================================
11
+ // Type Coercion Utilities
12
+ // ============================================================================
13
+ //
14
+ // LLMs sometimes produce tool arguments where a value that should be a number,
15
+ // boolean, array, or object is instead passed as a JSON-encoded string. For
16
+ // example, an array parameter might arrive as `"[1, 2, 3]"` instead of `[1, 2, 3]`.
17
+ //
18
+ // Rather than rejecting these outright, we attempt automatic coercion:
19
+ // 1. AJV validates the arguments and reports type errors
20
+ // 2. For each type error where the actual value is a string, we check if
21
+ // parsing it as JSON yields a value matching the expected type
22
+ // 3. If so, we replace the string with the parsed value and re-validate
23
+ //
24
+ // This is intentionally conservative: we only parse strings that look like
25
+ // valid JSON literals (objects, arrays, booleans, null, numbers) and only
26
+ // accept the result if it matches the schema's expected type.
27
+ // ============================================================================
28
+
29
+ /** Regex matching valid JSON number literals (integers, decimals, scientific notation) */
30
+ const JSON_NUMBER_PATTERN = /^[+-]?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$/;
31
+
32
+ /**
33
+ * Normalizes AJV's `params.type` into a consistent string array.
34
+ * AJV may report the expected type as a single string or an array of strings
35
+ * (for union types like `["string", "null"]`).
36
+ */
37
+ function normalizeExpectedTypes(typeParam: unknown): string[] {
38
+ if (typeof typeParam === "string") return [typeParam];
39
+ if (Array.isArray(typeParam)) {
40
+ return typeParam.filter((entry): entry is string => typeof entry === "string");
41
+ }
42
+ return [];
43
+ }
44
+
45
+ /**
46
+ * Checks if a value matches any of the expected JSON Schema types.
47
+ * Used to verify that a parsed JSON value is actually what the schema wants.
48
+ */
49
+ function matchesExpectedType(value: unknown, expectedTypes: string[]): boolean {
50
+ return expectedTypes.some((type) => {
51
+ switch (type) {
52
+ case "string":
53
+ return typeof value === "string";
54
+ case "number":
55
+ return typeof value === "number" && Number.isFinite(value);
56
+ case "integer":
57
+ return typeof value === "number" && Number.isInteger(value);
58
+ case "boolean":
59
+ return typeof value === "boolean";
60
+ case "null":
61
+ return value === null;
62
+ case "array":
63
+ return Array.isArray(value);
64
+ case "object":
65
+ return value !== null && typeof value === "object" && !Array.isArray(value);
66
+ default:
67
+ return false;
68
+ }
69
+ });
70
+ }
71
+
72
+ /**
73
+ * Attempts to parse a string as JSON if it looks like a JSON literal and
74
+ * the parsed result matches one of the expected types.
75
+ *
76
+ * Only attempts parsing for strings that syntactically look like JSON:
77
+ * - Objects: `{...}`
78
+ * - Arrays: `[...]`
79
+ * - Literals: `true`, `false`, `null`, or numeric strings
80
+ *
81
+ * Returns `{ changed: true }` only if parsing succeeded AND the result
82
+ * matches an expected type. This prevents false positives like parsing
83
+ * the string `"123"` when the schema actually wants a string.
84
+ */
85
+ function tryParseJsonForTypes(value: string, expectedTypes: string[]): { value: unknown; changed: boolean } {
86
+ const trimmed = value.trim();
87
+ if (!trimmed) return { value, changed: false };
88
+
89
+ // Quick syntactic checks to avoid unnecessary parse attempts
90
+ const looksJsonObject = trimmed.startsWith("{") && trimmed.endsWith("}");
91
+ const looksJsonArray = trimmed.startsWith("[") && trimmed.endsWith("]");
92
+ const looksJsonLiteral =
93
+ trimmed === "true" || trimmed === "false" || trimmed === "null" || JSON_NUMBER_PATTERN.test(trimmed);
94
+
95
+ if (!looksJsonObject && !looksJsonArray && !looksJsonLiteral) {
96
+ return { value, changed: false };
97
+ }
98
+
99
+ try {
100
+ const parsed = JSON.parse(trimmed) as unknown;
101
+ // Only accept if the parsed type matches what the schema expects
102
+ if (matchesExpectedType(parsed, expectedTypes)) {
103
+ return { value: parsed, changed: true };
104
+ }
105
+ } catch {
106
+ // Invalid JSON - leave as-is
107
+ return { value, changed: false };
108
+ }
109
+
110
+ return { value, changed: false };
111
+ }
112
+
113
+ // ============================================================================
114
+ // JSON Pointer Utilities (RFC 6901)
115
+ // ============================================================================
116
+ //
117
+ // AJV reports error locations using JSON Pointer syntax (e.g., `/foo/0/bar`).
118
+ // These utilities allow reading and writing values at those paths.
119
+ // ============================================================================
120
+
121
+ /**
122
+ * Decodes a JSON Pointer string into path segments.
123
+ * Handles RFC 6901 escape sequences: ~1 -> /, ~0 -> ~
124
+ */
125
+ function decodeJsonPointer(pointer: string): string[] {
126
+ if (!pointer) return [];
127
+ return pointer
128
+ .split("/")
129
+ .slice(1) // Remove leading empty segment from initial "/"
130
+ .map((segment) => segment.replace(/~1/g, "/").replace(/~0/g, "~"));
131
+ }
132
+
133
+ /**
134
+ * Retrieves a value from a nested object/array structure using a JSON Pointer.
135
+ * Returns undefined if the path doesn't exist or traversal fails.
136
+ */
137
+ function getValueAtPointer(root: unknown, pointer: string): unknown {
138
+ if (!pointer) return root;
139
+ const segments = decodeJsonPointer(pointer);
140
+ let current: unknown = root;
141
+
142
+ for (const segment of segments) {
143
+ if (current === null || current === undefined) return undefined;
144
+ if (Array.isArray(current)) {
145
+ const index = Number(segment);
146
+ if (!Number.isInteger(index)) return undefined;
147
+ current = current[index];
148
+ continue;
149
+ }
150
+ if (typeof current !== "object") return undefined;
151
+ current = (current as Record<string, unknown>)[segment];
152
+ }
153
+
154
+ return current;
155
+ }
156
+
157
+ /**
158
+ * Sets a value in a nested object/array structure using a JSON Pointer.
159
+ * Mutates the structure in-place. Returns the root (possibly unchanged if
160
+ * the path was invalid).
161
+ */
162
+ function setValueAtPointer(root: unknown, pointer: string, value: unknown): unknown {
163
+ if (!pointer) return value;
164
+ const segments = decodeJsonPointer(pointer);
165
+ let current: unknown = root;
166
+
167
+ // Navigate to the parent of the target location
168
+ for (let index = 0; index < segments.length - 1; index += 1) {
169
+ const segment = segments[index];
170
+ if (current === null || current === undefined) return root;
171
+ if (Array.isArray(current)) {
172
+ const arrayIndex = Number(segment);
173
+ if (!Number.isInteger(arrayIndex)) return root;
174
+ current = current[arrayIndex];
175
+ continue;
176
+ }
177
+ if (typeof current !== "object") return root;
178
+ current = (current as Record<string, unknown>)[segment];
179
+ }
180
+
181
+ // Set the value at the final segment
182
+ const lastSegment = segments[segments.length - 1];
183
+ if (Array.isArray(current)) {
184
+ const arrayIndex = Number(lastSegment);
185
+ if (!Number.isInteger(arrayIndex)) return root;
186
+ current[arrayIndex] = value;
187
+ return root;
188
+ }
189
+
190
+ if (typeof current !== "object" || current === null) return root;
191
+ (current as Record<string, unknown>)[lastSegment] = value;
192
+ return root;
193
+ }
194
+
195
+ /**
196
+ * Deep clones a JSON-serializable value.
197
+ * Uses structuredClone when available (faster), falls back to JSON round-trip.
198
+ */
199
+ function cloneJsonValue<T>(value: T): T {
200
+ if (typeof structuredClone === "function") {
201
+ return structuredClone(value);
202
+ }
203
+ return JSON.parse(JSON.stringify(value)) as T;
204
+ }
205
+
206
+ /**
207
+ * Attempts to fix type errors by parsing JSON-encoded strings.
208
+ *
209
+ * When AJV reports type errors, this function checks if the offending values
210
+ * are strings that contain valid JSON matching the expected type. If so, it
211
+ * returns a new args object with those strings replaced by their parsed values.
212
+ *
213
+ * The function is designed to be safe and conservative:
214
+ * - Only processes "type" errors (not format, pattern, etc.)
215
+ * - Only attempts coercion on string values
216
+ * - Only accepts parsed results that match the expected type
217
+ * - Clones the args object before mutation (copy-on-write)
218
+ */
219
+ function coerceArgsFromErrors(
220
+ args: unknown,
221
+ errors: Array<{ keyword?: string; instancePath?: string; params?: { type?: unknown } }> | null | undefined,
222
+ ): { value: unknown; changed: boolean } {
223
+ if (!errors || errors.length === 0) return { value: args, changed: false };
224
+
225
+ let changed = false;
226
+ let nextArgs: unknown = args;
227
+
228
+ for (const error of errors) {
229
+ // Only handle type mismatch errors
230
+ if (error.keyword !== "type") continue;
231
+
232
+ const instancePath = error.instancePath ?? "";
233
+ const expectedTypes = normalizeExpectedTypes(error.params?.type);
234
+ if (expectedTypes.length === 0) continue;
235
+
236
+ // Get the current value at the error location
237
+ const currentValue = getValueAtPointer(nextArgs, instancePath);
238
+ if (typeof currentValue !== "string") continue;
239
+
240
+ // Try to parse the string as JSON
241
+ const result = tryParseJsonForTypes(currentValue, expectedTypes);
242
+ if (!result.changed) continue;
243
+
244
+ // Clone on first modification (copy-on-write)
245
+ if (!changed) {
246
+ nextArgs = cloneJsonValue(nextArgs);
247
+ changed = true;
248
+ }
249
+ nextArgs = setValueAtPointer(nextArgs, instancePath, result.value);
250
+ }
251
+
252
+ return { value: changed ? nextArgs : args, changed };
253
+ }
254
+
10
255
  // Detect if we're in a browser extension environment with strict CSP
11
256
  // Chrome extensions with Manifest V3 don't allow eval/Function constructor
12
257
  const isBrowserExtension = typeof globalThis !== "undefined" && (globalThis as any).chrome?.runtime?.id !== undefined;
@@ -19,7 +264,6 @@ if (!isBrowserExtension) {
19
264
  ajv = new Ajv({
20
265
  allErrors: true,
21
266
  strict: false,
22
- coerceTypes: true,
23
267
  });
24
268
  addFormats(ajv);
25
269
  } catch (_e) {
@@ -51,19 +295,26 @@ export function validateToolCall(tools: Tool[], toolCall: ToolCall): any {
51
295
  * @throws Error with formatted message if validation fails
52
296
  */
53
297
  export function validateToolArguments(tool: Tool, toolCall: ToolCall): any {
298
+ const originalArgs = toolCall.arguments;
299
+
54
300
  // Skip validation in browser extension environment (CSP restrictions prevent AJV from working)
55
301
  if (!ajv || isBrowserExtension) {
56
302
  // Trust the LLM's output without validation
57
303
  // Browser extensions can't use AJV due to Manifest V3 CSP restrictions
58
- return toolCall.arguments;
304
+ return originalArgs;
59
305
  }
60
306
 
61
307
  // Compile the schema
62
308
  const validate = ajv.compile(tool.parameters);
63
309
 
64
310
  // Validate the arguments
65
- if (validate(toolCall.arguments)) {
66
- return toolCall.arguments;
311
+ if (validate(originalArgs)) {
312
+ return originalArgs;
313
+ }
314
+
315
+ const { value: coercedArgs, changed } = coerceArgsFromErrors(originalArgs, validate.errors);
316
+ if (changed && validate(coercedArgs)) {
317
+ return coercedArgs;
67
318
  }
68
319
 
69
320
  // Format validation errors nicely
@@ -75,7 +326,14 @@ export function validateToolArguments(tool: Tool, toolCall: ToolCall): any {
75
326
  })
76
327
  .join("\n") || "Unknown validation error";
77
328
 
78
- const errorMessage = `Validation failed for tool "${toolCall.name}":\n${errors}\n\nReceived arguments:\n${JSON.stringify(toolCall.arguments, null, 2)}`;
329
+ const receivedArgs = changed
330
+ ? {
331
+ original: originalArgs,
332
+ normalized: coercedArgs,
333
+ }
334
+ : originalArgs;
335
+
336
+ const errorMessage = `Validation failed for tool "${toolCall.name}":\n${errors}\n\nReceived arguments:\n${JSON.stringify(receivedArgs, null, 2)}`;
79
337
 
80
338
  throw new Error(errorMessage);
81
339
  }