mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-19 18:00:15 +01:00
🧩 fix: additionalProperties Handling and Ref Resolution in Zod Schemas (#8381)
* fix: false flagging object as empty object when it has `additionalProperties` field * 🔧 fix: Implement $ref resolution in JSON Schema handling * 🔧 fix: Resolve JSON Schema references before conversion to Zod * chore: move zod logic packages/api
This commit is contained in:
parent
8ca4cf3d2f
commit
4bbdc4c402
10 changed files with 518 additions and 33 deletions
|
|
@ -2,16 +2,17 @@ const { z } = require('zod');
|
||||||
const { tool } = require('@langchain/core/tools');
|
const { tool } = require('@langchain/core/tools');
|
||||||
const { logger } = require('@librechat/data-schemas');
|
const { logger } = require('@librechat/data-schemas');
|
||||||
const { Time, CacheKeys, StepTypes } = require('librechat-data-provider');
|
const { Time, CacheKeys, StepTypes } = require('librechat-data-provider');
|
||||||
const { sendEvent, normalizeServerName, MCPOAuthHandler } = require('@librechat/api');
|
|
||||||
const { Constants: AgentConstants, Providers, GraphEvents } = require('@librechat/agents');
|
const { Constants: AgentConstants, Providers, GraphEvents } = require('@librechat/agents');
|
||||||
|
const { Constants, ContentTypes, isAssistantsEndpoint } = require('librechat-data-provider');
|
||||||
const {
|
const {
|
||||||
Constants,
|
sendEvent,
|
||||||
ContentTypes,
|
MCPOAuthHandler,
|
||||||
isAssistantsEndpoint,
|
normalizeServerName,
|
||||||
|
resolveJsonSchemaRefs,
|
||||||
convertJsonSchemaToZod,
|
convertJsonSchemaToZod,
|
||||||
} = require('librechat-data-provider');
|
} = require('@librechat/api');
|
||||||
const { getMCPManager, getFlowStateManager } = require('~/config');
|
|
||||||
const { findToken, createToken, updateToken } = require('~/models');
|
const { findToken, createToken, updateToken } = require('~/models');
|
||||||
|
const { getMCPManager, getFlowStateManager } = require('~/config');
|
||||||
const { getCachedTools } = require('./Config');
|
const { getCachedTools } = require('./Config');
|
||||||
const { getLogStores } = require('~/cache');
|
const { getLogStores } = require('~/cache');
|
||||||
|
|
||||||
|
|
@ -113,7 +114,8 @@ async function createMCPTool({ req, res, toolKey, provider: _provider }) {
|
||||||
/** @type {LCTool} */
|
/** @type {LCTool} */
|
||||||
const { description, parameters } = toolDefinition;
|
const { description, parameters } = toolDefinition;
|
||||||
const isGoogle = _provider === Providers.VERTEXAI || _provider === Providers.GOOGLE;
|
const isGoogle = _provider === Providers.VERTEXAI || _provider === Providers.GOOGLE;
|
||||||
let schema = convertJsonSchemaToZod(parameters, {
|
const resolvedJsonSchema = resolveJsonSchemaRefs(parameters);
|
||||||
|
let schema = convertJsonSchemaToZod(resolvedJsonSchema, {
|
||||||
allowEmptyObject: !isGoogle,
|
allowEmptyObject: !isGoogle,
|
||||||
transformOneOfAnyOf: true,
|
transformOneOfAnyOf: true,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1074,7 +1074,7 @@
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @exports JsonSchemaType
|
* @exports JsonSchemaType
|
||||||
* @typedef {import('librechat-data-provider').JsonSchemaType} JsonSchemaType
|
* @typedef {import('@librechat/api').JsonSchemaType} JsonSchemaType
|
||||||
* @memberof typedefs
|
* @memberof typedefs
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
export * from './mcp/manager';
|
export * from './mcp/manager';
|
||||||
export * from './mcp/oauth';
|
export * from './mcp/oauth';
|
||||||
export * from './mcp/auth';
|
export * from './mcp/auth';
|
||||||
|
export * from './mcp/zod';
|
||||||
/* Utilities */
|
/* Utilities */
|
||||||
export * from './mcp/utils';
|
export * from './mcp/utils';
|
||||||
export * from './utils';
|
export * from './utils';
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
import { logger } from '@librechat/data-schemas';
|
import { logger } from '@librechat/data-schemas';
|
||||||
import { CallToolResultSchema, ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
|
import { CallToolResultSchema, ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
|
||||||
import type { RequestOptions } from '@modelcontextprotocol/sdk/shared/protocol.js';
|
|
||||||
import type { OAuthClientInformation } from '@modelcontextprotocol/sdk/shared/auth.js';
|
import type { OAuthClientInformation } from '@modelcontextprotocol/sdk/shared/auth.js';
|
||||||
import type { JsonSchemaType, TUser } from 'librechat-data-provider';
|
import type { RequestOptions } from '@modelcontextprotocol/sdk/shared/protocol.js';
|
||||||
import type { TokenMethods } from '@librechat/data-schemas';
|
import type { TokenMethods } from '@librechat/data-schemas';
|
||||||
import type { FlowStateManager } from '~/flow/manager';
|
import type { TUser } from 'librechat-data-provider';
|
||||||
import type { MCPOAuthTokens, MCPOAuthFlowMetadata } from './oauth/types';
|
import type { MCPOAuthTokens, MCPOAuthFlowMetadata } from './oauth/types';
|
||||||
|
import type { FlowStateManager } from '~/flow/manager';
|
||||||
|
import type { JsonSchemaType } from '~/types/zod';
|
||||||
import type { FlowMetadata } from '~/flow/types';
|
import type { FlowMetadata } from '~/flow/types';
|
||||||
import type * as t from './types';
|
import type * as t from './types';
|
||||||
import { CONSTANTS, isSystemUserId } from './enum';
|
import { CONSTANTS, isSystemUserId } from './enum';
|
||||||
|
|
@ -892,7 +893,7 @@ export class MCPManager {
|
||||||
this.updateUserLastActivity(userId);
|
this.updateUserLastActivity(userId);
|
||||||
}
|
}
|
||||||
this.checkIdleConnections();
|
this.checkIdleConnections();
|
||||||
return formatToolContent(result, provider);
|
return formatToolContent(result as t.MCPToolCallResponse, provider);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Log with context and re-throw or handle as needed
|
// Log with context and re-throw or handle as needed
|
||||||
logger.error(`${logPrefix}[${toolName}] Tool call failed`, error);
|
logger.error(`${logPrefix}[${toolName}] Tool call failed`, error);
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,9 @@ import {
|
||||||
WebSocketOptionsSchema,
|
WebSocketOptionsSchema,
|
||||||
StreamableHTTPOptionsSchema,
|
StreamableHTTPOptionsSchema,
|
||||||
} from 'librechat-data-provider';
|
} from 'librechat-data-provider';
|
||||||
import type { JsonSchemaType, TPlugin } from 'librechat-data-provider';
|
|
||||||
import type * as t from '@modelcontextprotocol/sdk/types.js';
|
import type * as t from '@modelcontextprotocol/sdk/types.js';
|
||||||
|
import type { TPlugin } from 'librechat-data-provider';
|
||||||
|
import type { JsonSchemaType } from '~/types/zod';
|
||||||
|
|
||||||
export type StdioOptions = z.infer<typeof StdioOptionsSchema>;
|
export type StdioOptions = z.infer<typeof StdioOptionsSchema>;
|
||||||
export type WebSocketOptions = z.infer<typeof WebSocketOptionsSchema>;
|
export type WebSocketOptions = z.infer<typeof WebSocketOptionsSchema>;
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
// zod.spec.ts
|
// zod.spec.ts
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { convertJsonSchemaToZod } from './zod';
|
import type { JsonSchemaType } from '~/types';
|
||||||
import type { JsonSchemaType } from './zod';
|
import { resolveJsonSchemaRefs, convertJsonSchemaToZod } from './zod';
|
||||||
|
|
||||||
describe('convertJsonSchemaToZod', () => {
|
describe('convertJsonSchemaToZod', () => {
|
||||||
describe('primitive types', () => {
|
describe('primitive types', () => {
|
||||||
|
|
@ -1083,4 +1083,403 @@ describe('convertJsonSchemaToZod', () => {
|
||||||
expect(() => zodSchema?.parse(true)).toThrow();
|
expect(() => zodSchema?.parse(true)).toThrow();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('additionalProperties with anyOf/oneOf and allowEmptyObject', () => {
|
||||||
|
it('should handle anyOf with object containing only additionalProperties when allowEmptyObject is false', () => {
|
||||||
|
const schema: JsonSchemaType & { anyOf?: any } = {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
filter: {
|
||||||
|
description: 'Filter field',
|
||||||
|
anyOf: [
|
||||||
|
{
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
_icontains: { type: 'string' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'null',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as JsonSchemaType & { anyOf?: any },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const zodSchema = convertJsonSchemaToZod(schema, {
|
||||||
|
allowEmptyObject: false,
|
||||||
|
transformOneOfAnyOf: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(zodSchema).toBeDefined();
|
||||||
|
|
||||||
|
const testData = {
|
||||||
|
filter: {
|
||||||
|
title: {
|
||||||
|
_icontains: 'Pirate',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = zodSchema?.parse(testData);
|
||||||
|
expect(result).toEqual(testData);
|
||||||
|
expect(result?.filter).toBeDefined();
|
||||||
|
expect(result?.filter?.title?._icontains).toBe('Pirate');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not treat objects with additionalProperties as empty', () => {
|
||||||
|
const schema: JsonSchemaType = {
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: {
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const zodSchemaWithoutAllow = convertJsonSchemaToZod(schema, {
|
||||||
|
allowEmptyObject: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should not return undefined because it has additionalProperties
|
||||||
|
expect(zodSchemaWithoutAllow).toBeDefined();
|
||||||
|
|
||||||
|
const testData = {
|
||||||
|
customField: 'value',
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(zodSchemaWithoutAllow?.parse(testData)).toEqual(testData);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle oneOf with object containing only additionalProperties', () => {
|
||||||
|
const schema: JsonSchemaType & { oneOf?: any } = {
|
||||||
|
type: 'object',
|
||||||
|
properties: {},
|
||||||
|
oneOf: [
|
||||||
|
{
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
specificField: { type: 'string' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const zodSchema = convertJsonSchemaToZod(schema, {
|
||||||
|
allowEmptyObject: false,
|
||||||
|
transformOneOfAnyOf: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(zodSchema).toBeDefined();
|
||||||
|
|
||||||
|
// Test with additional properties
|
||||||
|
const testData1 = {
|
||||||
|
randomField: 'value',
|
||||||
|
anotherField: 123,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(zodSchema?.parse(testData1)).toEqual(testData1);
|
||||||
|
|
||||||
|
// Test with specific field
|
||||||
|
const testData2 = {
|
||||||
|
specificField: 'test',
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(zodSchema?.parse(testData2)).toEqual(testData2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle complex nested schema with $ref-like structure', () => {
|
||||||
|
const schema: JsonSchemaType & { anyOf?: any } = {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
query: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
filter: {
|
||||||
|
description: 'Filter conditions',
|
||||||
|
anyOf: [
|
||||||
|
{
|
||||||
|
// This simulates a resolved $ref
|
||||||
|
anyOf: [
|
||||||
|
{
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
_or: {
|
||||||
|
type: 'array',
|
||||||
|
items: { type: 'object' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['_or'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: {
|
||||||
|
anyOf: [
|
||||||
|
{
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
_icontains: { type: 'string' },
|
||||||
|
_eq: { type: 'string' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'null',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as JsonSchemaType & { anyOf?: any },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const zodSchema = convertJsonSchemaToZod(schema, {
|
||||||
|
allowEmptyObject: false,
|
||||||
|
transformOneOfAnyOf: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(zodSchema).toBeDefined();
|
||||||
|
|
||||||
|
const testData = {
|
||||||
|
query: {
|
||||||
|
filter: {
|
||||||
|
title: {
|
||||||
|
_icontains: 'Pirate',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = zodSchema?.parse(testData);
|
||||||
|
expect(result).toEqual(testData);
|
||||||
|
expect(result?.query?.filter?.title?._icontains).toBe('Pirate');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('$ref resolution with resolveJsonSchemaRefs', () => {
|
||||||
|
it('should handle schemas with $ref references when resolved', () => {
|
||||||
|
const schemaWithRefs = {
|
||||||
|
type: 'object' as const,
|
||||||
|
properties: {
|
||||||
|
collection: {
|
||||||
|
type: 'string' as const,
|
||||||
|
},
|
||||||
|
query: {
|
||||||
|
type: 'object' as const,
|
||||||
|
properties: {
|
||||||
|
filter: {
|
||||||
|
anyOf: [{ $ref: '#/$defs/__schema0' }, { type: 'null' as const }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['collection', 'query'],
|
||||||
|
$defs: {
|
||||||
|
__schema0: {
|
||||||
|
anyOf: [
|
||||||
|
{
|
||||||
|
type: 'object' as const,
|
||||||
|
properties: {
|
||||||
|
_or: {
|
||||||
|
type: 'array' as const,
|
||||||
|
items: { $ref: '#/$defs/__schema0' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['_or'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'object' as const,
|
||||||
|
additionalProperties: {
|
||||||
|
anyOf: [
|
||||||
|
{
|
||||||
|
type: 'object' as const,
|
||||||
|
properties: {
|
||||||
|
_eq: {
|
||||||
|
anyOf: [
|
||||||
|
{ type: 'string' as const },
|
||||||
|
{ type: 'number' as const },
|
||||||
|
{ type: 'null' as const },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// First test without resolving refs - should not work properly
|
||||||
|
const zodSchemaUnresolved = convertJsonSchemaToZod(schemaWithRefs as any, {
|
||||||
|
allowEmptyObject: true,
|
||||||
|
transformOneOfAnyOf: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const testData = {
|
||||||
|
collection: 'posts',
|
||||||
|
query: {
|
||||||
|
filter: {
|
||||||
|
status: {
|
||||||
|
_eq: 'draft',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Without resolving refs, the filter field won't work correctly
|
||||||
|
const resultUnresolved = zodSchemaUnresolved?.parse(testData);
|
||||||
|
expect(resultUnresolved?.query?.filter).toEqual({});
|
||||||
|
|
||||||
|
// Now resolve refs first
|
||||||
|
const resolvedSchema = resolveJsonSchemaRefs(schemaWithRefs);
|
||||||
|
|
||||||
|
// Verify refs were resolved
|
||||||
|
expect(resolvedSchema.properties?.query?.properties?.filter?.anyOf?.[0]).not.toHaveProperty(
|
||||||
|
'$ref',
|
||||||
|
);
|
||||||
|
expect(resolvedSchema.properties?.query?.properties?.filter?.anyOf?.[0]).toHaveProperty(
|
||||||
|
'anyOf',
|
||||||
|
);
|
||||||
|
|
||||||
|
const zodSchemaResolved = convertJsonSchemaToZod(resolvedSchema as any, {
|
||||||
|
allowEmptyObject: true,
|
||||||
|
transformOneOfAnyOf: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// With resolved refs, it should work correctly
|
||||||
|
const resultResolved = zodSchemaResolved?.parse(testData);
|
||||||
|
expect(resultResolved).toEqual(testData);
|
||||||
|
expect(resultResolved?.query?.filter?.status?._eq).toBe('draft');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle circular $ref references without infinite loops', () => {
|
||||||
|
const schemaWithCircularRefs = {
|
||||||
|
type: 'object' as const,
|
||||||
|
properties: {
|
||||||
|
node: { $ref: '#/$defs/TreeNode' },
|
||||||
|
},
|
||||||
|
$defs: {
|
||||||
|
TreeNode: {
|
||||||
|
type: 'object' as const,
|
||||||
|
properties: {
|
||||||
|
value: { type: 'string' as const },
|
||||||
|
children: {
|
||||||
|
type: 'array' as const,
|
||||||
|
items: { $ref: '#/$defs/TreeNode' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Should not throw or hang
|
||||||
|
const resolved = resolveJsonSchemaRefs(schemaWithCircularRefs);
|
||||||
|
expect(resolved).toBeDefined();
|
||||||
|
|
||||||
|
// The circular reference should be broken with a simple object schema
|
||||||
|
const zodSchema = convertJsonSchemaToZod(resolved as any, {
|
||||||
|
allowEmptyObject: true,
|
||||||
|
transformOneOfAnyOf: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(zodSchema).toBeDefined();
|
||||||
|
|
||||||
|
const testData = {
|
||||||
|
node: {
|
||||||
|
value: 'root',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
value: 'child1',
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(() => zodSchema?.parse(testData)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle various edge cases safely', () => {
|
||||||
|
// Test with null/undefined
|
||||||
|
expect(resolveJsonSchemaRefs(null as any)).toBeNull();
|
||||||
|
expect(resolveJsonSchemaRefs(undefined as any)).toBeUndefined();
|
||||||
|
|
||||||
|
// Test with non-object primitives
|
||||||
|
expect(resolveJsonSchemaRefs('string' as any)).toBe('string');
|
||||||
|
expect(resolveJsonSchemaRefs(42 as any)).toBe(42);
|
||||||
|
expect(resolveJsonSchemaRefs(true as any)).toBe(true);
|
||||||
|
|
||||||
|
// Test with arrays
|
||||||
|
const arrayInput = [{ type: 'string' }, { $ref: '#/def' }];
|
||||||
|
const arrayResult = resolveJsonSchemaRefs(arrayInput as any);
|
||||||
|
expect(Array.isArray(arrayResult)).toBe(true);
|
||||||
|
expect(arrayResult).toHaveLength(2);
|
||||||
|
|
||||||
|
// Test with schema that has no refs
|
||||||
|
const noRefSchema = {
|
||||||
|
type: 'object' as const,
|
||||||
|
properties: {
|
||||||
|
name: { type: 'string' as const },
|
||||||
|
nested: {
|
||||||
|
type: 'object' as const,
|
||||||
|
properties: {
|
||||||
|
value: { type: 'number' as const },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolvedNoRef = resolveJsonSchemaRefs(noRefSchema);
|
||||||
|
expect(resolvedNoRef).toEqual(noRefSchema);
|
||||||
|
|
||||||
|
// Test with invalid ref (non-existent)
|
||||||
|
const invalidRefSchema = {
|
||||||
|
type: 'object' as const,
|
||||||
|
properties: {
|
||||||
|
item: { $ref: '#/$defs/nonExistent' },
|
||||||
|
},
|
||||||
|
$defs: {
|
||||||
|
other: { type: 'string' as const },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolvedInvalid = resolveJsonSchemaRefs(invalidRefSchema);
|
||||||
|
// Invalid refs should be preserved as-is
|
||||||
|
expect(resolvedInvalid.properties?.item?.$ref).toBe('#/$defs/nonExistent');
|
||||||
|
|
||||||
|
// Test with empty object
|
||||||
|
expect(resolveJsonSchemaRefs({})).toEqual({});
|
||||||
|
|
||||||
|
// Test with schema containing special JSON Schema keywords
|
||||||
|
const schemaWithKeywords = {
|
||||||
|
type: 'object' as const,
|
||||||
|
properties: {
|
||||||
|
value: {
|
||||||
|
type: 'string' as const,
|
||||||
|
minLength: 5,
|
||||||
|
maxLength: 10,
|
||||||
|
pattern: '^[A-Z]',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
additionalProperties: false,
|
||||||
|
minProperties: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolvedKeywords = resolveJsonSchemaRefs(schemaWithKeywords);
|
||||||
|
expect(resolvedKeywords).toEqual(schemaWithKeywords);
|
||||||
|
expect(resolvedKeywords.properties?.value?.minLength).toBe(5);
|
||||||
|
expect(resolvedKeywords.additionalProperties).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -1,30 +1,16 @@
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
import type { JsonSchemaType, ConvertJsonSchemaToZodOptions } from '~/types';
|
||||||
export type JsonSchemaType = {
|
|
||||||
type: 'string' | 'number' | 'boolean' | 'array' | 'object';
|
|
||||||
enum?: string[];
|
|
||||||
items?: JsonSchemaType;
|
|
||||||
properties?: Record<string, JsonSchemaType>;
|
|
||||||
required?: string[];
|
|
||||||
description?: string;
|
|
||||||
additionalProperties?: boolean | JsonSchemaType;
|
|
||||||
};
|
|
||||||
|
|
||||||
function isEmptyObjectSchema(jsonSchema?: JsonSchemaType): boolean {
|
function isEmptyObjectSchema(jsonSchema?: JsonSchemaType): boolean {
|
||||||
return (
|
return (
|
||||||
jsonSchema != null &&
|
jsonSchema != null &&
|
||||||
typeof jsonSchema === 'object' &&
|
typeof jsonSchema === 'object' &&
|
||||||
jsonSchema.type === 'object' &&
|
jsonSchema.type === 'object' &&
|
||||||
(jsonSchema.properties == null || Object.keys(jsonSchema.properties).length === 0)
|
(jsonSchema.properties == null || Object.keys(jsonSchema.properties).length === 0) &&
|
||||||
|
!jsonSchema.additionalProperties // Don't treat objects with additionalProperties as empty
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type ConvertJsonSchemaToZodOptions = {
|
|
||||||
allowEmptyObject?: boolean;
|
|
||||||
dropFields?: string[];
|
|
||||||
transformOneOfAnyOf?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
function dropSchemaFields(
|
function dropSchemaFields(
|
||||||
schema: JsonSchemaType | undefined,
|
schema: JsonSchemaType | undefined,
|
||||||
fields: string[],
|
fields: string[],
|
||||||
|
|
@ -98,6 +84,10 @@ function convertToZodUnion(
|
||||||
return convertJsonSchemaToZod(objSchema, options);
|
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);
|
return convertJsonSchemaToZod(objSchema, options);
|
||||||
} else if (!subSchema.type && subSchema.items) {
|
} else if (!subSchema.type && subSchema.items) {
|
||||||
// It's likely an array schema
|
// It's likely an array schema
|
||||||
|
|
@ -182,6 +172,82 @@ function convertToZodUnion(
|
||||||
return zodSchemas[0];
|
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(
|
export function convertJsonSchemaToZod(
|
||||||
schema: JsonSchemaType & Record<string, unknown>,
|
schema: JsonSchemaType & Record<string, unknown>,
|
||||||
options: ConvertJsonSchemaToZodOptions = {},
|
options: ConvertJsonSchemaToZodOptions = {},
|
||||||
|
|
@ -4,3 +4,4 @@ export * from './google';
|
||||||
export * from './mistral';
|
export * from './mistral';
|
||||||
export * from './openai';
|
export * from './openai';
|
||||||
export * from './run';
|
export * from './run';
|
||||||
|
export * from './zod';
|
||||||
|
|
|
||||||
15
packages/api/src/types/zod.ts
Normal file
15
packages/api/src/types/zod.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
export type JsonSchemaType = {
|
||||||
|
type: 'string' | 'number' | 'boolean' | 'array' | 'object';
|
||||||
|
enum?: string[];
|
||||||
|
items?: JsonSchemaType;
|
||||||
|
properties?: Record<string, JsonSchemaType>;
|
||||||
|
required?: string[];
|
||||||
|
description?: string;
|
||||||
|
additionalProperties?: boolean | JsonSchemaType;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ConvertJsonSchemaToZodOptions = {
|
||||||
|
allowEmptyObject?: boolean;
|
||||||
|
dropFields?: string[];
|
||||||
|
transformOneOfAnyOf?: boolean;
|
||||||
|
};
|
||||||
|
|
@ -8,7 +8,6 @@ export * from './artifacts';
|
||||||
/* schema helpers */
|
/* schema helpers */
|
||||||
export * from './parsers';
|
export * from './parsers';
|
||||||
export * from './ocr';
|
export * from './ocr';
|
||||||
export * from './zod';
|
|
||||||
/* custom/dynamic configurations */
|
/* custom/dynamic configurations */
|
||||||
export * from './generate';
|
export * from './generate';
|
||||||
export * from './models';
|
export * from './models';
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue