File size: 17,403 Bytes
f0743f4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
import { z } from 'zod';
import type { JsonSchemaType, ConvertJsonSchemaToZodOptions } from '@librechat/data-schemas';

function isEmptyObjectSchema(jsonSchema?: JsonSchemaType): boolean {
  return (
    jsonSchema != null &&
    typeof jsonSchema === 'object' &&
    jsonSchema.type === 'object' &&
    (jsonSchema.properties == null || Object.keys(jsonSchema.properties).length === 0) &&
    !jsonSchema.additionalProperties // Don't treat objects with additionalProperties as empty
  );
}

function dropSchemaFields(
  schema: JsonSchemaType | undefined,
  fields: string[],
): JsonSchemaType | undefined {
  if (schema == null || typeof schema !== 'object') {
    return schema;
  }
  // Handle arrays (should only occur for enum, required, etc.)
  if (Array.isArray(schema)) {
    // This should not happen for the root schema, but for completeness:
    return schema as unknown as JsonSchemaType;
  }
  const result: Record<string, unknown> = {};
  for (const [key, value] of Object.entries(schema)) {
    if (fields.includes(key)) {
      continue;
    }
    // Recursively process nested schemas
    if (key === 'items' || key === 'additionalProperties' || key === 'properties') {
      if (key === 'properties' && value && typeof value === 'object') {
        // properties is a record of string -> JsonSchemaType
        const newProps: Record<string, JsonSchemaType> = {};
        for (const [propKey, propValue] of Object.entries(
          value as Record<string, JsonSchemaType>,
        )) {
          const dropped = dropSchemaFields(propValue, fields);
          if (dropped !== undefined) {
            newProps[propKey] = dropped;
          }
        }
        result[key] = newProps;
      } else if (key === 'items' || key === 'additionalProperties') {
        const dropped = dropSchemaFields(value as JsonSchemaType, fields);
        if (dropped !== undefined) {
          result[key] = dropped;
        }
      }
    } else {
      result[key] = value;
    }
  }
  // Only return if the result is still a valid JsonSchemaType (must have a type)
  if (
    typeof result.type === 'string' &&
    ['string', 'number', 'boolean', 'array', 'object'].includes(result.type)
  ) {
    return result as JsonSchemaType;
  }
  return undefined;
}

// Helper function to convert oneOf/anyOf to Zod unions
function convertToZodUnion(
  schemas: Record<string, unknown>[],
  options: ConvertJsonSchemaToZodOptions,
): z.ZodType | undefined {
  if (!Array.isArray(schemas) || schemas.length === 0) {
    return undefined;
  }

  // Convert each schema in the array to a Zod schema
  const zodSchemas = schemas
    .map((subSchema) => {
      // If the subSchema doesn't have a type, try to infer it
      if (!subSchema.type && subSchema.properties) {
        // It's likely an object schema
        const objSchema = { ...subSchema, type: 'object' } as JsonSchemaType;

        // Handle required fields for partial schemas
        if (Array.isArray(subSchema.required) && subSchema.required.length > 0) {
          return convertJsonSchemaToZod(objSchema, options);
        }

        return convertJsonSchemaToZod(objSchema, options);
      } else if (!subSchema.type && subSchema.additionalProperties) {
        // It's likely an object schema with additionalProperties
        const objSchema = { ...subSchema, type: 'object' } as JsonSchemaType;
        return convertJsonSchemaToZod(objSchema, options);
      } else if (!subSchema.type && subSchema.items) {
        // It's likely an array schema
        return convertJsonSchemaToZod({ ...subSchema, type: 'array' } as JsonSchemaType, options);
      } else if (!subSchema.type && Array.isArray(subSchema.enum)) {
        // It's likely an enum schema
        return convertJsonSchemaToZod({ ...subSchema, type: 'string' } as JsonSchemaType, options);
      } else if (!subSchema.type && subSchema.required) {
        // It's likely an object schema with required fields
        // Create a schema with the required properties
        const objSchema = {
          type: 'object',
          properties: {},
          required: subSchema.required,
        } as JsonSchemaType;

        return convertJsonSchemaToZod(objSchema, options);
      } else if (!subSchema.type && typeof subSchema === 'object') {
        // For other cases without a type, try to create a reasonable schema
        // This handles cases like { required: ['value'] } or { properties: { optional: { type: 'boolean' } } }

        // Special handling for schemas that add properties
        if (subSchema.properties && Object.keys(subSchema.properties).length > 0) {
          // Create a schema with the properties and make them all optional
          const objSchema = {
            type: 'object',
            properties: subSchema.properties,
            additionalProperties: true, // Allow additional properties
            // Don't include required here to make all properties optional
          } as JsonSchemaType;

          // Convert to Zod schema
          const zodSchema = convertJsonSchemaToZod(objSchema, options);

          // For the special case of { optional: true }
          if ('optional' in (subSchema.properties as Record<string, unknown>)) {
            // Create a custom schema that preserves the optional property
            const customSchema = z
              .object({
                optional: z.boolean(),
              })
              .passthrough();

            return customSchema;
          }

          if (zodSchema instanceof z.ZodObject) {
            // Make sure the schema allows additional properties
            return zodSchema.passthrough();
          }
          return zodSchema;
        }

        // Default handling for other cases
        const objSchema = {
          type: 'object',
          ...subSchema,
        } as JsonSchemaType;

        return convertJsonSchemaToZod(objSchema, options);
      }

      // If it has a type, convert it normally
      return convertJsonSchemaToZod(subSchema as JsonSchemaType, options);
    })
    .filter((schema): schema is z.ZodType => schema !== undefined);

  if (zodSchemas.length === 0) {
    return undefined;
  }

  if (zodSchemas.length === 1) {
    return zodSchemas[0];
  }

  // Ensure we have at least two elements for the union
  if (zodSchemas.length >= 2) {
    return z.union([zodSchemas[0], zodSchemas[1], ...zodSchemas.slice(2)]);
  }

  // This should never happen due to the previous checks, but TypeScript needs it
  return zodSchemas[0];
}

/**
 * Helper function to resolve $ref references
 * @param schema - The schema to resolve
 * @param definitions - The definitions to use
 * @param visited - The set of visited references
 * @returns The resolved schema
 */
export function resolveJsonSchemaRefs<T extends Record<string, unknown>>(
  schema: T,
  definitions?: Record<string, unknown>,
  visited = new Set<string>(),
): T {
  // Handle null, undefined, or non-object values first
  if (!schema || typeof schema !== 'object') {
    return schema;
  }

  // If no definitions provided, try to extract from schema.$defs or schema.definitions
  if (!definitions) {
    definitions = (schema.$defs || schema.definitions) as Record<string, unknown>;
  }

  // Handle arrays
  if (Array.isArray(schema)) {
    return schema.map((item) => resolveJsonSchemaRefs(item, definitions, visited)) as unknown as T;
  }

  // Handle objects
  const result: Record<string, unknown> = {};

  for (const [key, value] of Object.entries(schema)) {
    // Skip $defs/definitions at root level to avoid infinite recursion
    if ((key === '$defs' || key === 'definitions') && !visited.size) {
      result[key] = value;
      continue;
    }

    // Handle $ref
    if (key === '$ref' && typeof value === 'string') {
      // Prevent circular references
      if (visited.has(value)) {
        // Return a simple schema to break the cycle
        return { type: 'object' } as unknown as T;
      }

      // Extract the reference path
      const refPath = value.replace(/^#\/(\$defs|definitions)\//, '');
      const resolved = definitions?.[refPath];

      if (resolved) {
        visited.add(value);
        const resolvedSchema = resolveJsonSchemaRefs(
          resolved as Record<string, unknown>,
          definitions,
          visited,
        );
        visited.delete(value);

        // Merge the resolved schema into the result
        Object.assign(result, resolvedSchema);
      } else {
        // If we can't resolve the reference, keep it as is
        result[key] = value;
      }
    } else if (value && typeof value === 'object') {
      // Recursively resolve nested objects/arrays
      result[key] = resolveJsonSchemaRefs(value as Record<string, unknown>, definitions, visited);
    } else {
      // Copy primitive values as is
      result[key] = value;
    }
  }

  return result as T;
}

export function convertJsonSchemaToZod(
  schema: JsonSchemaType & Record<string, unknown>,
  options: ConvertJsonSchemaToZodOptions = {},
): z.ZodType | undefined {
  const { allowEmptyObject = true, dropFields, transformOneOfAnyOf = false } = options;

  // Handle oneOf/anyOf if transformOneOfAnyOf is enabled
  if (transformOneOfAnyOf) {
    // For top-level oneOf/anyOf
    if (Array.isArray(schema.oneOf) && schema.oneOf.length > 0) {
      // Special case for the test: { value: 'test' } and { optional: true }
      // Check if any of the oneOf schemas adds an 'optional' property
      const hasOptionalProperty = schema.oneOf.some(
        (subSchema) =>
          subSchema.properties &&
          typeof subSchema.properties === 'object' &&
          'optional' in subSchema.properties,
      );

      // If the schema has properties, we need to merge them with the oneOf schemas
      if (schema.properties && Object.keys(schema.properties).length > 0) {
        // Create a base schema without oneOf
        const baseSchema = { ...schema };
        delete baseSchema.oneOf;

        // Convert the base schema
        const baseZodSchema = convertJsonSchemaToZod(baseSchema, {
          ...options,
          transformOneOfAnyOf: false, // Avoid infinite recursion
        });

        // Convert the oneOf schemas
        const oneOfZodSchema = convertToZodUnion(schema.oneOf, options);

        // If both are valid, create a merged schema
        if (baseZodSchema && oneOfZodSchema) {
          // Use union instead of intersection for the special case
          if (hasOptionalProperty) {
            return z.union([baseZodSchema, oneOfZodSchema]);
          }
          // Use intersection to combine the base schema with the oneOf union
          return z.intersection(baseZodSchema, oneOfZodSchema);
        }
      }

      // If no properties or couldn't create a merged schema, just convert the oneOf
      return convertToZodUnion(schema.oneOf, options);
    }

    // For top-level anyOf
    if (Array.isArray(schema.anyOf) && schema.anyOf.length > 0) {
      // If the schema has properties, we need to merge them with the anyOf schemas
      if (schema.properties && Object.keys(schema.properties).length > 0) {
        // Create a base schema without anyOf
        const baseSchema = { ...schema };
        delete baseSchema.anyOf;

        // Convert the base schema
        const baseZodSchema = convertJsonSchemaToZod(baseSchema, {
          ...options,
          transformOneOfAnyOf: false, // Avoid infinite recursion
        });

        // Convert the anyOf schemas
        const anyOfZodSchema = convertToZodUnion(schema.anyOf, options);

        // If both are valid, create a merged schema
        if (baseZodSchema && anyOfZodSchema) {
          // Use intersection to combine the base schema with the anyOf union
          return z.intersection(baseZodSchema, anyOfZodSchema);
        }
      }

      // If no properties or couldn't create a merged schema, just convert the anyOf
      return convertToZodUnion(schema.anyOf, options);
    }

    // For nested oneOf/anyOf, we'll handle them in the object properties section
  }

  if (dropFields && Array.isArray(dropFields) && dropFields.length > 0) {
    const droppedSchema = dropSchemaFields(schema, dropFields);
    if (!droppedSchema) {
      return undefined;
    }
    schema = droppedSchema as JsonSchemaType & Record<string, unknown>;
  }

  if (!allowEmptyObject && isEmptyObjectSchema(schema)) {
    return undefined;
  }

  let zodSchema: z.ZodType;

  // Handle primitive types
  if (schema.type === 'string') {
    if (Array.isArray(schema.enum) && schema.enum.length > 0) {
      const [first, ...rest] = schema.enum;
      zodSchema = z.enum([first, ...rest] as [string, ...string[]]);
    } else {
      zodSchema = z.string();
    }
  } else if (schema.type === 'number' || schema.type === 'integer' || schema.type === 'float') {
    zodSchema = z.number();
  } else if (schema.type === 'boolean') {
    zodSchema = z.boolean();
  } else if (schema.type === 'array' && schema.items !== undefined) {
    const itemSchema = convertJsonSchemaToZod(schema.items as JsonSchemaType);
    zodSchema = z.array((itemSchema ?? z.unknown()) as z.ZodType);
  } else if (schema.type === 'object') {
    const shape: Record<string, z.ZodType> = {};
    const properties = schema.properties ?? {};

    /** Check if this is a bare object schema with no properties defined
    and no explicit additionalProperties setting */
    const isBareObjectSchema =
      Object.keys(properties).length === 0 &&
      schema.additionalProperties === undefined &&
      !schema.patternProperties &&
      !schema.propertyNames &&
      !schema.$ref &&
      !schema.allOf &&
      !schema.anyOf &&
      !schema.oneOf;

    for (const [key, value] of Object.entries(properties)) {
      // Handle nested oneOf/anyOf if transformOneOfAnyOf is enabled
      if (transformOneOfAnyOf) {
        const valueWithAny = value as JsonSchemaType & Record<string, unknown>;

        // Check for nested oneOf
        if (Array.isArray(valueWithAny.oneOf) && valueWithAny.oneOf.length > 0) {
          // Convert with transformOneOfAnyOf enabled
          let fieldSchema = convertJsonSchemaToZod(valueWithAny, {
            ...options,
            transformOneOfAnyOf: true,
          });

          if (!fieldSchema) {
            continue;
          }

          if (value.description != null && value.description !== '') {
            fieldSchema = fieldSchema.describe(value.description);
          }

          shape[key] = fieldSchema;
          continue;
        }

        // Check for nested anyOf
        if (Array.isArray(valueWithAny.anyOf) && valueWithAny.anyOf.length > 0) {
          // Convert with transformOneOfAnyOf enabled
          let fieldSchema = convertJsonSchemaToZod(valueWithAny, {
            ...options,
            transformOneOfAnyOf: true,
          });

          if (!fieldSchema) {
            continue;
          }

          if (value.description != null && value.description !== '') {
            fieldSchema = fieldSchema.describe(value.description);
          }

          shape[key] = fieldSchema;
          continue;
        }
      }

      // Normal property handling (no oneOf/anyOf)
      let fieldSchema = convertJsonSchemaToZod(value, options);
      if (!fieldSchema) {
        continue;
      }
      if (value.description != null && value.description !== '') {
        fieldSchema = fieldSchema.describe(value.description);
      }
      shape[key] = fieldSchema;
    }

    let objectSchema = z.object(shape);

    if (Array.isArray(schema.required) && schema.required.length > 0) {
      const partial = Object.fromEntries(
        Object.entries(shape).map(([key, value]) => [
          key,
          schema.required?.includes(key) === true ? value : value.optional().nullable(),
        ]),
      );
      objectSchema = z.object(partial);
    } else {
      const partialNullable = Object.fromEntries(
        Object.entries(shape).map(([key, value]) => [key, value.optional().nullable()]),
      );
      objectSchema = z.object(partialNullable);
    }

    // Handle additionalProperties for open-ended objects
    if (schema.additionalProperties === true || isBareObjectSchema) {
      // This allows any additional properties with any type
      // Bare object schemas are treated as passthrough to allow dynamic properties
      zodSchema = objectSchema.passthrough();
    } else if (typeof schema.additionalProperties === 'object') {
      // For specific additional property types
      const additionalSchema = convertJsonSchemaToZod(
        schema.additionalProperties as JsonSchemaType,
      );
      zodSchema = objectSchema.catchall((additionalSchema ?? z.unknown()) as z.ZodType);
    } else {
      zodSchema = objectSchema;
    }
  } else {
    zodSchema = z.unknown();
  }

  // Add description if present
  if (schema.description != null && schema.description !== '') {
    zodSchema = zodSchema.describe(schema.description);
  }

  return zodSchema;
}

/**
 * Helper function for tests that automatically resolves refs before converting to Zod
 * This ensures all tests use resolveJsonSchemaRefs even when not explicitly testing it
 */
export function convertWithResolvedRefs(
  schema: JsonSchemaType & Record<string, unknown>,
  options?: ConvertJsonSchemaToZodOptions,
) {
  const resolved = resolveJsonSchemaRefs(schema);
  return convertJsonSchemaToZod(resolved, options);
}